diff options
author | ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> | 2022-06-25 19:49:53 +0300 |
---|---|---|
committer | ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> | 2022-08-30 15:58:01 +0300 |
commit | 7aeea1757000741a04409dadeaf9fab3966b399d (patch) | |
tree | 752b86f6b37e58de8b43c98841556eb1c064ee3b | |
parent | b858e6cb4c7998eb3ab3ed0c624b0106fed1b4a5 (diff) |
feat: unify HTTP server and SW renderer
-rw-r--r-- | docs/api.md | 146 | ||||
-rw-r--r-- | docs/tutorials.md | 62 | ||||
-rw-r--r-- | index.js | 88 | ||||
-rw-r--r-- | lib/file.js | 23 | ||||
-rw-r--r-- | lib/server.js | 504 | ||||
-rw-r--r-- | lib/torrent.js | 9 | ||||
-rw-r--r-- | lib/worker-server.js | 26 | ||||
-rw-r--r-- | package.json | 1 |
8 files changed, 490 insertions, 369 deletions
diff --git a/docs/api.md b/docs/api.md index 3df2daa..e2bf759 100644 --- a/docs/api.md +++ b/docs/api.md @@ -245,12 +245,86 @@ Sets the maximum speed at which the client uploads the torrents, in bytes/sec. `rate` must be bigger or equal than zero, or `-1` to disable the upload throttle and use the whole bandwidth of the connection. -## `client.loadWorker(controller, [function callback (controller) {}])` *(browser only)* -Accepts an existing service worker registration [navigator.serviceWorker.controller] which must be activated, "creates" a file server for streamed file rendering to use. +## `client.createServer([opts], force)` -Needs either [this worker](https://github.com/webtorrent/webtorrent/blob/master/sw.min.js) to be used, or have [this functionality](https://github.com/webtorrent/webtorrent/blob/master/lib/worker.js) implemented. +Create an http server to serve the contents of this torrent, dynamically fetching the needed torrent pieces to satisfy http requests. Range requests are supported. +If `opts` is specified, it can have the following properties: +```js +{ + origin: String // Allow requests from specific origin. `false` for same-origin. [default: '*'] + hostname: String // If specified, only allow requests whose `Host` header matches this hostname. Note that you should not specify the port since this is automatically determined by the server. Ex: `localhost` [default: `undefined`]. NodeJS only. + path: String // Allows to overwrite the default `/webtorrent` base path. [default: '/webtorrent']. NodeJS only. + controller: ServiceWorkerRegistration // Accepts an existing service worker registration [await navigator.serviceWorker.getRegistration()]. Browser only. Required! +} +``` + +If `force` is specified, it can force WebTorrent to use a specific implementation for enviorments which run both Node and Browser like NW.js or Electron. Allowed values: +```js +'browser' || 'node' +``` + +Visiting the root of the server `/` won't show anything. Visiting `/webtorrent/` will list all torrents. Access individual torrents at `/webtorrent/<infohash>` where `infohash` is the hash of the torrent. To acceess individual files, go to `/webtorrent/<infoHash>/<filepath>` where filepath is the file's path in the torrent. + + +Here is a usage example for Node.js: + +```js +const client = new WebTorrent() +const magnetURI = 'magnet: ...' + +client.add(magnetURI, function (torrent) { + // create HTTP server for this torrent + const instance = torrent.createServer() + instance.server.listen(port) // start the server listening to a port + const url = torrent.files[0].getStreamURL() + console.log(url) + + // visit http://localhost:<port>/webtorrent/ to see a list of torrents + + // access individual torrents at http://localhost:<port>/webtorrent/<infoHash> where infoHash is the hash of the torrent + + // later, cleanup... + instance.close() + client.destroy() +}) +``` + +In browser needs either [this worker](https://github.com/webtorrent/webtorrent/blob/master/sw.min.js) to be used, or have [this functionality](https://github.com/webtorrent/webtorrent/blob/master/lib/worker.js) implemented. + +Here is a user example for browser: + +```js +const client = new WebTorrent() +const magnetURI = 'magnet: ...' +const player = document.querySelector('video') +const scope = './' + +function download (instance) { + client.add(magnetURI, torrent => { + const url = torrent.files[0].getStreamURL() + console.log(url) + + // visit <origin>/webtorrent/ to see a list of torrents, where origin is the worker registration scope. + + // access individual torrents at /webtorrent/<infoHash> where infoHash is the hash of the torrent + + // later, cleanup... + instance.close() + client.destroy() + }) +} +navigator.serviceWorker.register('./sw.min.js', { scope }).then(reg => { + const worker = reg.active || reg.waiting || reg.installing + function checkState (worker) { + return worker.state === 'activated' && download(client.createServer({ controller: reg })) + } + if (!checkState(worker)) { + worker.addEventListener('statechange', ({ target }) => checkState(target)) + } +}) +``` # Torrent API ## `torrent.name` @@ -431,46 +505,6 @@ Deprioritizes a range of previously selected pieces. Marks a range of pieces as critical priority to be downloaded ASAP. From `start` to `end` (both inclusive). -## `torrent.createServer([opts])` - -Create an http server to serve the contents of this torrent, dynamically fetching the -needed torrent pieces to satisfy http requests. Range requests are supported. - -Returns an `http.Server` instance (got from calling `http.createServer`). If -`opts` is specified, it can have the following properties: - -```js -{ - origin: String // Allow requests from specific origin. `false` for same-origin. [default: '*'] - hostname: String // If specified, only allow requests whose `Host` header matches this hostname. Note that you should not specify the port since this is automatically determined by the server. Ex: `localhost` [default: `undefined`] -} -``` - -Visiting the root of the server `/` will show a list of links to individual files. Access -individual files at `/<index>` where `<index>` is the index in the `torrent.files` array -(e.g. `/0`, `/1`, etc.) - -Here is a usage example: - -```js -const client = new WebTorrent() -const magnetURI = 'magnet: ...' - -client.add(magnetURI, function (torrent) { - // create HTTP server for this torrent - const server = torrent.createServer() - server.listen(port) // start the server listening to a port - - // visit http://localhost:<port>/ to see a list of files - - // access individual files at http://localhost:<port>/<index> where index is the index - // in the torrent.files array - - // later, cleanup... - server.close() - client.destroy() -}) -``` ## `torrent.pause()` @@ -674,9 +708,9 @@ file.getBlobURL(function (err, url) { }) ``` -## `file.streamTo(elem, [function callback (err, elem) {}])` *(browser only)* +## `file.streamTo(elem)` *(browser only)* -Requires `client.loadWorker` to be ran beforehand. Sets the element source to the file's streaming URL. Supports streaming, seeking and all browser codecs and containers. +Requires `client.createServer` to be ran beforehand. Sets the element source to the file's streaming URL. Supports streaming, seeking and all browser codecs and containers. Support table: |Containers|Chromium|Mobile Chromium|Edge Chromium|Firefox| @@ -722,24 +756,23 @@ Support table: \* Might not work in some video containers. -## `file.getStreamURL(elem, [function callback (err, elem) {}])` *(browser only)* +## `file.getStreamURL()` *(browser only)* -Requires `client.loadWorker` to be ran beforehand. +Requires `client.createServer` to be ran beforehand. This method is useful for creating a file download link, like this: ```js -file.getStreamURL((err, url) => { - if (err) throw err - const a = document.createElement('a') - a.target = "_blank" - a.href = url - a.textContent = 'Download ' + file.name - document.body.append(a) -}) +const url = file.getStreamURL() +if (err) throw err +const a = document.createElement('a') +a.target = "_blank" +a.href = url +a.textContent = 'Download ' + file.name +document.body.append(a) ``` -## `file.on('stream', function ({ stream, file, req }, function pipeCallback) {})` *(browser only)* +## `file.on('stream', function ({ stream, file, req }, function pipeCallback) {})` This is advanced functionality. @@ -757,7 +790,6 @@ Example usage: file.on('stream', ({ stream, file, req }, cb) => { if (req.destination === 'audio' && file.name.endsWith('.dts')) { const transcoder = new SomeAudioTranscoder() - stream.pipe(transcoder) cb(transcoder) // do other things } diff --git a/docs/tutorials.md b/docs/tutorials.md index 5f080db..dfe67e9 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -12,8 +12,9 @@ Code example: import WebTorrent from 'webtorrent' const client = new WebTorrent() -const torrentId = "magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fsintel.torrent" +const torrentId = 'magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fsintel.torrent' const player = document.querySelector('video') +const scope = './' function download () { client.add(torrentId, torrent => { @@ -26,15 +27,14 @@ function download () { } }) // Stream to a <video> element by providing an the DOM element - file.streamTo(player, () => { - console.log('Ready to play!') - }) - } + file.streamTo(player) + console.log('Ready to play!') + }) } -navigator.serviceWorker.register('sw.min.js', { scope }).then(reg => { +navigator.serviceWorker.register('./sw.min.js', { scope }).then(reg => { const worker = reg.active || reg.waiting || reg.installing function checkState (worker) { - return worker.state === 'activated' && client.loadWorker(worker, download) + return worker.state === 'activated' && client.createServer({ controller: reg }) && download() } if (!checkState(worker)) { worker.addEventListener('statechange', ({ target }) => checkState(target)) @@ -82,30 +82,30 @@ Code example: <body> <video id="video-container" class="video-js" data-setup="{}" controls="true"></video> <script> - const client = new WebTorrent() - const torrentId = "magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fsintel.torrent" - const player = document.querySelector("video#video-container_html5_api") - - function download () { - client.add(torrentId, torrent => { - // Torrents can contain many files. Let's use the .mp4 file - const file = torrent.files.find(file => file.name.endsWith('.mp4')) - - // Stream to a <video> element by providing an the DOM element - file.streamTo(player, () => { - console.log('Ready to play!') - }) - }) - } - navigator.serviceWorker.register('sw.min.js', { scope }).then(reg => { - const worker = reg.active || reg.waiting || reg.installing - function checkState (worker) { - return worker.state === 'activated' && client.loadWorker(worker, download) - } - if (!checkState(worker)) { - worker.addEventListener('statechange', ({ target }) => checkState(target)) - } - }) +const client = new WebTorrent() +const torrentId = 'magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fsintel.torrent' +const player = document.querySelector('video') +const scope = './' + +function download () { + client.add(torrentId, torrent => { + // Torrents can contain many files. Let's use the .mp4 file + const file = torrent.files.find(file => file.name.endsWith('.mp4')) + + // Stream to a <video> element by providing an the DOM element + file.streamTo(player) + console.log('Ready to play!') + }) +} +navigator.serviceWorker.register('./sw.min.js', { scope }).then(reg => { + const worker = reg.active || reg.waiting || reg.installing + function checkState (worker) { + return worker.state === 'activated' && client.createServer({ controller: reg }) && download() + } + if (!checkState(worker)) { + worker.addEventListener('statechange', ({ target }) => checkState(target)) + } +}) </script> </body> </html> @@ -1,5 +1,5 @@ /*! webtorrent. MIT License. WebTorrent LLC <https://webtorrent.io/opensource> */ -/* global FileList, ServiceWorker */ +/* global FileList */ /* eslint-env browser */ const EventEmitter = require('events') @@ -19,6 +19,7 @@ const throughput = require('throughput') const { ThrottleGroup } = require('speed-limiter') const ConnPool = require('./lib/conn-pool.js') // browser exclude const Torrent = require('./lib/torrent.js') +const { NodeServer, BrowserServer } = require('./lib/server.js') const { version: VERSION } = require('./package.json') const debug = debugFactory('webtorrent') @@ -84,10 +85,6 @@ class WebTorrent extends EventEmitter { this._downloadLimit = Math.max((typeof opts.downloadLimit === 'number') ? opts.downloadLimit : -1, -1) this._uploadLimit = Math.max((typeof opts.uploadLimit === 'number') ? opts.uploadLimit : -1, -1) - this.serviceWorker = null - this.workerKeepAliveInterval = null - this.workerPortCount = 0 - if (opts.secure === true) { require('./lib/peer').enableSecure() } @@ -165,69 +162,28 @@ class WebTorrent extends EventEmitter { } /** - * Accepts an existing service worker registration [navigator.serviceWorker.controller] - * which must be activated, "creates" a file server for streamed file rendering to use. + * Creates an http server to serve the contents of this torrent, + * dynamically fetching the needed torrent pieces to satisfy http requests. + * Range requests are supported. * - * @param {ServiceWorker} controller - * @param {function=} cb - * @return {null} + * @param {Object} options + * @param {String} force + * @return {BrowserServer||NodeServer} */ - loadWorker (controller, cb = () => {}) { - if (!(controller instanceof ServiceWorker)) throw new Error('Invalid worker registration') - if (controller.state !== 'activated') throw new Error('Worker isn\'t activated') - const keepAliveTime = 20000 - - this.serviceWorker = controller - - navigator.serviceWorker.addEventListener('message', event => { - const { data } = event - if (!data.type || !data.type === 'webtorrent' || !data.url) return null - let [infoHash, ...filePath] = data.url.slice(data.url.indexOf(data.scope + 'webtorrent/') + 11 + data.scope.length).split('/') - filePath = decodeURI(filePath.join('/')) - if (!infoHash || !filePath) return null - - const [port] = event.ports - - const file = this.get(infoHash) && this.get(infoHash).files.find(file => file.path === filePath) - if (!file) return null - - const [response, stream, raw] = file._serve(data) - const asyncIterator = stream && stream[Symbol.asyncIterator]() - - const cleanup = () => { - port.onmessage = null - if (stream) stream.destroy() - if (raw) raw.destroy() - this.workerPortCount-- - if (!this.workerPortCount) { - clearInterval(this.workerKeepAliveInterval) - this.workerKeepAliveInterval = null - } - } - - port.onmessage = async msg => { - if (msg.data) { - let chunk - try { - chunk = (await asyncIterator.next()).value - } catch (e) { - // chunk is yet to be downloaded or it somehow failed, should this be logged? - } - port.postMessage(chunk) - if (!chunk) cleanup() - if (!this.workerKeepAliveInterval) this.workerKeepAliveInterval = setInterval(() => fetch(`${this.serviceWorker.scriptURL.slice(0, this.serviceWorker.scriptURL.lastIndexOf('/') + 1).slice(window.location.origin.length)}webtorrent/keepalive/`), keepAliveTime) - } else { - cleanup() - } - } - this.workerPortCount++ - port.postMessage(response) - }) - // test if browser supports cancelling sw Readable Streams - fetch(`${this.serviceWorker.scriptURL.slice(0, this.serviceWorker.scriptURL.lastIndexOf('/') + 1).slice(window.location.origin.length)}webtorrent/cancel/`).then(res => { - res.body.cancel() - }) - cb(null, this.serviceWorker) + createServer (options, force) { + if (this.destroyed) throw new Error('torrent is destroyed') + if (this._server) throw new Error('server already created') + if ((typeof window === 'undefined' || force === 'node') && force !== 'browser') { + // node implementation + this._server = new NodeServer(this, options) + return this._server + } else { + // browser implementation + if (!(options?.controller instanceof ServiceWorkerRegistration)) throw new Error('Invalid worker registration') + if (options.controller.active.state !== 'activated') throw new Error('Worker isn\'t activated') + this._server = new BrowserServer(this, options) + return this._server + } } get downloadSpeed () { return this._downloadSpeed() } diff --git a/lib/file.js b/lib/file.js index f84fb66..9b4df87 100644 --- a/lib/file.js +++ b/lib/file.js @@ -35,7 +35,7 @@ class File extends EventEmitter { this.emit('done') } - this._serviceWorker = torrent.client.serviceWorker + this._server = torrent.client._server } get downloaded () { @@ -190,22 +190,15 @@ class File extends EventEmitter { return [res, pipe || stream, pipe && stream] } - getStreamURL (cb = () => {}) { - if (typeof window === 'undefined') throw new Error('browser-only method') - if (!this._serviceWorker) throw new Error('No worker registered') - if (this._serviceWorker.state !== 'activated') throw new Error('Worker isn\'t activated') - const workerPath = this._serviceWorker.scriptURL.slice(0, this._serviceWorker.scriptURL.lastIndexOf('/') + 1).slice(window.location.origin.length) - const url = `${workerPath}webtorrent/${this._torrent.infoHash}/${encodeURI(this.path)}` - cb(null, url) + getStreamURL () { + if (!this._server) throw new Error('No server created') + const url = `${this._server.pathname}/${this._torrent.infoHash}/${encodeURI(this.path)}` + return url } - streamTo (elem, cb = () => {}) { - if (typeof window === 'undefined') throw new Error('browser-only method') - if (!this._serviceWorker) throw new Error('No worker registered') - if (this._serviceWorker.state !== 'activated') throw new Error('Worker isn\'t activated') - const workerPath = this._serviceWorker.scriptURL.slice(0, this._serviceWorker.scriptURL.lastIndexOf('/') + 1).slice(window.location.origin.length) - elem.src = `${workerPath}webtorrent/${this._torrent.infoHash}/${encodeURI(this.path)}` - cb(null, elem) + streamTo (elem) { + elem.src = this.getStreamURL() + return elem } includes (piece) { diff --git a/lib/server.js b/lib/server.js index 7e08906..7b980e4 100644 --- a/lib/server.js +++ b/lib/server.js @@ -4,248 +4,394 @@ const mime = require('mime') const pump = require('pump') const rangeParser = require('range-parser') const queueMicrotask = require('queue-microtask') +const { Readable } = require('stream') -function Server (torrent, opts = {}) { - const server = http.createServer() - if (!opts.origin) opts.origin = '*' // allow all origins by default - - const sockets = new Set() - const pendingReady = new Set() - let closed = false - const _listen = server.listen - const _close = server.close - - server.listen = (...args) => { - closed = false - server.on('connection', onConnection) - server.on('request', onRequest) - return _listen.apply(server, args) - } +const keepAliveTime = 20000 - server.close = cb => { - closed = true - server.removeListener('connection', onConnection) - server.removeListener('request', onRequest) - pendingReady.forEach(onReady => { - torrent.removeListener('ready', onReady) - }) - pendingReady.clear() - _close.call(server, cb) +class ServerBase { + constructor (client, opts = {}) { + this.client = client + if (!opts.origin) opts.origin = '*' // allow all origins by default + this.opts = opts } - server.destroy = cb => { - sockets.forEach(socket => { - socket.destroy() - }) - - // Only call `server.close` if user has not called it already - if (!cb) cb = () => {} - if (closed) queueMicrotask(cb) - else server.close(cb) - torrent = null + static serveIndexPage (res, torrents) { + const listHtml = torrents + .map(torrent => ( + `<li> + <a href="${escapeHtml(torrent.infoHash)}"> + ${escapeHtml(torrent.name)} + </a> + (${escapeHtml(torrent.length)} bytes) + </li>` + )) + .join('<br>') + + + res.status = 200 + res.headers['Content-Type'] = 'text/html' + res.body = getPageHTML( + 'WebTorrent', + `<h1>WebTorrent</h1> + <ol>${listHtml}</ol>` + ) + + return res } - function isOriginAllowed (req) { + isOriginAllowed (req) { // When `origin` option is `false`, deny all cross-origin requests - if (opts.origin === false) return false - - // Requests without an 'Origin' header are not actually cross-origin, so just - // deny them - if (req.headers.origin == null) return false + if (this.opts.origin === false) return false // The user allowed all origins - if (opts.origin === '*') return true + if (this.opts.origin === '*') return true // Allow requests where the 'Origin' header matches the `opts.origin` setting - return req.headers.origin === opts.origin + return req.headers.origin === this.opts.origin } - function onConnection (socket) { - socket.setTimeout(36000000) - sockets.add(socket) - socket.once('close', () => { - sockets.delete(socket) - }) + static serveMethodNotAllowed (res) { + res.status = 405 + res.headers['Content-Type'] = 'text/html' + + res.body = getPageHTML( + '405 - Method Not Allowed', + '<h1>405 - Method Not Allowed</h1>' + ) + + return res } - function onRequest (req, res) { - // If a 'hostname' string is specified, deny requests with a 'Host' - // header that does not match the origin of the torrent server to prevent - // DNS rebinding attacks. - if (opts.hostname && req.headers.host !== `${opts.hostname}:${server.address().port}`) { - return req.destroy() + static serve404Page (res) { + res.status = 404 + res.headers['Content-Type'] = 'text/html' + + res.body = getPageHTML( + '404 - Not Found', + '<h1>404 - Not Found</h1>' + ) + return res + } + + static serveTorrentPage (torrent, res) { + const listHtml = torrent.files + .map(file => ( + `<li> + <a href="${torrent.infoHash}/${escapeHtml(file.path)}"> + ${escapeHtml(file.path)} + </a> + (${escapeHtml(file.length)} bytes) + </li>` + )) + .join('<br>') + + + res.status = 200 + res.headers['Content-Type'] = 'text/html' + + res.body = getPageHTML( + `${escapeHtml(torrent.name)} - WebTorrent`, + `<h1>${escapeHtml(torrent.name)}</h1> + <ol>${listHtml}</ol>` + ) + + return res + } + + static serveOptionsRequest (req, res) { + res.status = 204 // no content + res.headers['Access-Control-Max-Age'] = '600' + res.headers['Access-Control-Allow-Methods'] = 'GET,HEAD' + + + if (req.headers['access-control-request-headers']) { + res.headers['Access-Control-Allow-Headers'] = req.headers['access-control-request-headers'] } + return res + } - const pathname = new URL(req.url, 'http://example.com').pathname + static serveFile (file, req, res) { + res.status = 200 + + // Disable caching as data is local anyways + res.headers.Expires = '0' + res.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' + // Support range-requests + res.headers['Accept-Ranges'] = 'bytes' + res.headers['Content-Type'] = mime.getType(file.name) || 'application/octet-stream' + // Support DLNA streaming + res.headers['transferMode.dlna.org'] = 'Streaming' + res.headers['contentFeatures.dlna.org'] = 'DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000' + + // Force the browser to download the file if if it's opened in a new tab + // Set name of file (for "Save Page As..." dialog) + if (req.destination === 'document') { + res.headers['Content-Type'] = 'application/octet-stream' + res.headers['Content-Disposition'] = `attachment; filename*=UTF-8''${encodeRFC5987(file.name)}` + res.body = 'DOWNLOAD' + } else { + res.headers['Content-Disposition'] = `inline; filename*=UTF-8''${encodeRFC5987(file.name)}` + } - // Allow cross-origin requests (CORS) - if (isOriginAllowed(req)) { - res.setHeader('Access-Control-Allow-Origin', req.headers.origin) + // `rangeParser` returns an array of ranges, or an error code (number) if + // there was an error parsing the range. + let range = rangeParser(file.length, req.headers.range || '') + + if (Array.isArray(range)) { + res.status = 206 // indicates that range-request was understood + + // no support for multi-range request, just use the first range + range = range[0] + + res.headers['Content-Range'] = `bytes ${range.start}-${range.end}/${file.length}` + + res.headers['Content-Length'] = range.end - range.start + 1 + } else { + res.statusCode = 200 + range = null + res.headers['Content-Length'] = file.length + } + + + const stream = req.method === 'GET' && file.createReadStream(range) + + let pipe = null + if (stream) { + file.emit('stream', { stream, req, file }, target => { + pipe = pump(stream, target) + }) } + res.body = pipe || stream + return res + } - // Prevent browser mime-type sniffing - res.setHeader('X-Content-Type-Options', 'nosniff') + onRequest (req, cb) { + let pathname = new URL(req.url, 'http://example.com').pathname + pathname = pathname.slice(pathname.indexOf(this.pathname) + this.pathname.length + 1) - // Defense-in-depth: Set a strict Content Security Policy to mitigate XSS - res.setHeader('Content-Security-Policy', "base-uri 'none'; default-src 'none'; frame-ancestors 'none'; form-action 'none';") + const res = { + headers: { + // Prevent browser mime-type sniffing + 'X-Content-Type-Options': 'nosniff', + // Defense-in-depth: Set a strict Content Security Policy to mitigate XSS + 'Content-Security-Policy': "base-uri 'none'; frame-ancestors 'none'; form-action 'none';" + } + } + + // Allow cross-origin requests (CORS) + if (this.isOriginAllowed(req)) { + res.headers['Access-Control-Allow-Origin'] = this.opts.origin === '*' ? '*' : req.headers.origin + } - if (pathname === '/favicon.ico') { - return serve404Page() + if (pathname === 'favicon.ico') { + return cb(ServerBase.serve404Page(res)) } // Allow CORS requests to specify arbitrary headers, e.g. 'Range', // by responding to the OPTIONS preflight request with the specified // origin and requested headers. if (req.method === 'OPTIONS') { - if (isOriginAllowed(req)) return serveOptionsRequest() - else return serveMethodNotAllowed() + if (this.isOriginAllowed(req)) return cb(ServerBase.serveOptionsRequest(req, res)) + else return cb(ServerBase.serveMethodNotAllowed(res)) + } + + const onReady = () => { + this.pendingReady.delete(onReady) + cb(handleRequest()) + } + + const handleRequest = () => { + if (pathname === '') { + return ServerBase.serveIndexPage(res, this.client.torrents) + } + + let [infoHash, ...filePath] = pathname.split('/') + filePath = decodeURI(filePath.join('/')) + + const torrent = this.client.get(infoHash) + if (!infoHash || !torrent) { + return ServerBase.serve404Page(res) + } + + if (!filePath) { + return ServerBase.serveTorrentPage(torrent, res) + } + + const file = torrent.files.find(file => file.path.replace(/\\/, '/') === filePath) + if (!file) { + return ServerBase.serve404Page(res) + } + return ServerBase.serveFile(file, req, res) } if (req.method === 'GET' || req.method === 'HEAD') { - if (torrent.ready) { - return handleRequest() + if (this.client.ready) { + return cb(handleRequest()) } else { - pendingReady.add(onReady) - torrent.once('ready', onReady) + this.pendingReady.add(onReady) + this.client.once('ready', onReady) return } } - return serveMethodNotAllowed() + return ServerBase.serveMethodNotAllowed(res) + } - function serveOptionsRequest () { - res.statusCode = 204 // no content - res.setHeader('Access-Control-Max-Age', '600') - res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD') + close (cb = () => {}) { + this.closed = true + this.pendingReady.forEach(onReady => { + this.client.removeListener('ready', onReady) + }) + this.pendingReady.clear() + queueMicrotask(cb) + } - if (req.headers['access-control-request-headers']) { - res.setHeader( - 'Access-Control-Allow-Headers', - req.headers['access-control-request-headers'] - ) - } - res.end() + destroy (cb = () => {}) { + // Only call `server.close` if user has not called it already + if (this.closed) queueMicrotask(cb) + else this.close(cb) + this.client = null + } +} + +class NodeServer extends ServerBase { + constructor (client, opts) { + super(client, opts) + + this.server = http.createServer() + + this.sockets = new Set() + this.pendingReady = new Set() + this.closed = false + this.pathname = opts?.pathname || '/webtorrent' + } + + wrapRequest (req, res) { + // If a 'hostname' string is specified, deny requests with a 'Host' + // header that does not match the origin of the torrent server to prevent + // DNS rebinding attacks. + if (this.opts.hostname && req.headers.host !== `${this.opts.hostname}:${this.server.address().port}`) { + return req.destroy() } - function onReady () { - pendingReady.delete(onReady) - handleRequest() + if (!new URL(req.url, 'http://example.com').pathname.startsWith(this.pathname)) { + return req.destroy() } - function handleRequest () { - if (pathname === '/') { - return serveIndexPage() - } + this.onRequest(req, ({ status, headers, body }) => { + res.writeHead(status, headers) - const index = Number(pathname.split('/')[1]) - if (Number.isNaN(index) || index >= torrent.files.length) { - return serve404Page() + if (body instanceof Readable) { // this is probably a bad way of checking? idk + pump(body, res) + } else { + res.end(body) } + }) + } - const file = torrent.files[index] - serveFile(file) - } + onConnection (socket) { + socket.setTimeout(36000000) + this.sockets.add(socket) + socket.once('close', () => { + this.sockets.delete(socket) + }) + } - function serveIndexPage () { - res.statusCode = 200 - res.setHeader('Content-Type', 'text/html') - - const listHtml = torrent.files - .map((file, i) => ( - `<li> - <a - download="${escapeHtml(file.name)}" - href="${escapeHtml(i)}/${escapeHtml(file.name)}" - > - ${escapeHtml(file.path)} - </a> - (${escapeHtml(file.length)} bytes) - </li>` - )) - .join('<br>') - - const html = getPageHTML( - `${escapeHtml(torrent.name)} - WebTorrent`, - ` - <h1>${escapeHtml(torrent.name)}</h1> - <ol>${listHtml}</ol> - ` - ) - res.end(html) - } + listen (...args) { + this.closed = false + this.server.on('connection', this.onConnection.bind(this)) + this.server.on('request', this.wrapRequest.bind(this)) + return this.server.listen(...args) + } - function serve404Page () { - res.statusCode = 404 - res.setHeader('Content-Type', 'text/html') + close (cb) { + this.server.removeListener('connection', this.onConnection) + this.server.removeListener('request', this.wrapRequest) + this.server.close(cb) + super.close() + } - const html = getPageHTML( - '404 - Not Found', - '<h1>404 - Not Found</h1>' - ) - res.end(html) - } + destroy (cb) { + this.sockets.forEach(socket => { + socket.destroy() + }) + super.destroy(cb) + } +} - function serveFile (file) { - res.setHeader('Content-Type', mime.getType(file.name) || 'application/octet-stream') +class BrowserServer extends ServerBase { + constructor (client, opts) { + super(client, opts) - // Support range-requests - res.setHeader('Accept-Ranges', 'bytes') + this.registration = opts.controller + this.workerKeepAliveInterval = null + this.workerPortCount = 0 - // Set name of file (for "Save Page As..." dialog) - res.setHeader( - 'Content-Disposition', - `inline; filename*=UTF-8''${encodeRFC5987(file.name)}` - ) + this.pathname = new URL(opts.controller.scope).pathname + 'webtorrent' - // Support DLNA streaming - res.setHeader('transferMode.dlna.org', 'Streaming') - res.setHeader( - 'contentFeatures.dlna.org', - 'DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000' - ) + navigator.serviceWorker.addEventListener('message', this.wrapRequest.bind(this)) + // test if browser supports cancelling sw Readable Streams + fetch(`${this.pathname}/cancel/`).then(res => { + res.body.cancel() + }) + } - // `rangeParser` returns an array of ranges, or an error code (number) if - // there was an error parsing the range. - let range = rangeParser(file.length, req.headers.range || '') + wrapRequest (event) { + const req = event.data - if (Array.isArray(range)) { - res.statusCode = 206 // indicates that range-request was understood + if (!req?.type === 'webtorrent' || !req.url) return null - // no support for multi-range request, just use the first range - range = range[0] + const [port] = event.ports + this.onRequest(req, ({ status, headers, body }) => { + const asyncIterator = body instanceof Readable && body[Symbol.asyncIterator]() - res.setHeader( - 'Content-Range', - `bytes ${range.start}-${range.end}/${file.length}` - ) - res.setHeader('Content-Length', range.end - range.start + 1) - } else { - res.statusCode = 200 - range = null - res.setHeader('Content-Length', file.length) + const cleanup = () => { + port.onmessage = null + if (body?.destroy) body.destroy() + this.workerPortCount-- + if (!this.workerPortCount) { + clearInterval(this.workerKeepAliveInterval) + this.workerKeepAliveInterval = null + } } - if (req.method === 'HEAD') { - return res.end() + port.onmessage = async msg => { + if (msg.data) { + let chunk + try { + chunk = (await asyncIterator.next()).value + } catch (e) { + // chunk is yet to be downloaded or it somehow failed, should this be logged? + } + port.postMessage(chunk) + if (!chunk) cleanup() + if (!this.workerKeepAliveInterval) { + this.workerKeepAliveInterval = setInterval(() => fetch(`${this.pathname}/keepalive/`), keepAliveTime) + } + } else { + cleanup() + } } + this.workerPortCount++ + port.postMessage({ + status, + headers, + body: asyncIterator ? 'STREAM' : body + }) + }) + } - pump(file.createReadStream(range), res) - } - - function serveMethodNotAllowed () { - res.statusCode = 405 - res.setHeader('Content-Type', 'text/html') - const html = getPageHTML( - '405 - Method Not Allowed', - '<h1>405 - Method Not Allowed</h1>' - ) - res.end(html) - } + close (cb) { + navigator.serviceWorker.removeEventListener('message'.this.wrapRequest.bind(this)) + super.close(cb) } - return server + destroy (cb) { + super.destroy(cb) + } } + // NOTE: Arguments must already be HTML-escaped function getPageHTML (title, pageHtml) { return ` @@ -274,4 +420,4 @@ function encodeRFC5987 (str) { .replace(/%(?:7C|60|5E)/g, unescape) } -module.exports = Server +module.exports = { NodeServer, BrowserServer } diff --git a/lib/torrent.js b/lib/torrent.js index 363181d..eb2edad 100644 --- a/lib/torrent.js +++ b/lib/torrent.js @@ -33,7 +33,6 @@ const utPex = require('ut_pex') // browser exclude const File = require('./file.js') const Peer = require('./peer.js') const RarityMap = require('./rarity-map.js') -const Server = require('./server.js') // browser exclude const utp = require('./utp.js') // browser exclude const WebConn = require('./webconn.js') @@ -1846,14 +1845,6 @@ class Torrent extends EventEmitter { }) } - createServer (requestListener) { - if (typeof Server !== 'function') throw new Error('node.js-only method') - if (this.destroyed) throw new Error('torrent is destroyed') - const server = new Server(this, requestListener) - this._servers.push(server) - return server - } - pause () { if (this.destroyed) return this._debug('pause') diff --git a/lib/worker-server.js b/lib/worker-server.js index 428b908..9c3133b 100644 --- a/lib/worker-server.js +++ b/lib/worker-server.js @@ -41,9 +41,18 @@ async function serve ({ request }) { } }) - if (data.body !== 'STREAM' && data.body !== 'DOWNLOAD') return new Response(data.body, data) - let timeOut = null + const cleanup = () => { + port.postMessage(false) // send a cancel request + clearTimeout(timeOut) + port.onmessage = null + } + + if (data.body !== 'STREAM') { + cleanup() + return new Response(data.body, data) + } + return new Response(new ReadableStream({ pull (controller) { return new Promise(resolve => { @@ -51,9 +60,8 @@ async function serve ({ request }) { if (data) { controller.enqueue(data) // data is Uint8Array } else { - clearTimeout(timeOut) + cleanup() controller.close() // data is null, means the stream ended - port.onmessage = null } resolve() } @@ -61,11 +69,9 @@ async function serve ({ request }) { // firefox doesn't support cancelling of Readable Streams in service workers, // so we just empty it after 5s of inactivity, the browser will request another port anyways clearTimeout(timeOut) - if (data.body === 'STREAM') { + if (destination !== 'document') { timeOut = setTimeout(() => { - controller.close() - port.postMessage(false) // send timeout - port.onmessage = null + cleanup() resolve() }, portTimeoutDuration) } @@ -74,9 +80,7 @@ async function serve ({ request }) { }) }, cancel () { - port.postMessage(false) // send a cancel request - clearTimeout(timeOut) - port.onmessage = null + cleanup() } }), data) } diff --git a/package.json b/package.json index 9d006b9..0562e58 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "url": "https://webtorrent.io" }, "browser": { - "./lib/server.js": false, "./lib/conn-pool.js": false, "./lib/utp.js": false, "bittorrent-dht/client": false, |