Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/webtorrent/webtorrent.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThaUnknown <6506529+ThaUnknown@users.noreply.github.com>2022-06-25 19:49:53 +0300
committerThaUnknown <6506529+ThaUnknown@users.noreply.github.com>2022-08-30 15:58:01 +0300
commit7aeea1757000741a04409dadeaf9fab3966b399d (patch)
tree752b86f6b37e58de8b43c98841556eb1c064ee3b
parentb858e6cb4c7998eb3ab3ed0c624b0106fed1b4a5 (diff)
feat: unify HTTP server and SW renderer
-rw-r--r--docs/api.md146
-rw-r--r--docs/tutorials.md62
-rw-r--r--index.js88
-rw-r--r--lib/file.js23
-rw-r--r--lib/server.js504
-rw-r--r--lib/torrent.js9
-rw-r--r--lib/worker-server.js26
-rw-r--r--package.json1
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>
diff --git a/index.js b/index.js
index 99b07af..4f375d7 100644
--- a/index.js
+++ b/index.js
@@ -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,