diff --git a/.gitignore b/.gitignore index 827c878..4ba0313 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ sandbox.js sandbox coverage package-lock.json +.vscode diff --git a/README.md b/README.md index 621d966..dd2a4d1 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,60 @@ const link = server.getLink(key, { }) ``` +## API + +#### `const server = new BlobServer(store, options)` + +`store` - Corestore instance + +`options`: +```js +{ + port // defaults to 49833, + host // defaults to '127.0.0.1', + token // server token + protocol // 'http' | 'https' +} +``` + +#### `await server.listen()` +Listen to requests + +#### `const link = server.getLink(key, options)` + +Generates the url used to fetch data + +`key` - hypercore or hyperdrive key + +`options`: +```js +{ + host // custom host + port // custom port + protocol: 'http' | 'https', + filename | blob +} +``` +`filename` - hyperdrive filename + +`blob` - blob ID in the form of `{ blockOffset, blockLength, byteOffset, byteLength}` + +When downloading blobs, you can set the `Range` header to download sections of data, implement pause/resume download functionality. Offsets are zero-indexed & inclusive + +``` +Range: bytes=- +Range: bytes=0-300 +Range: bytes=2- +``` + +#### `await server.suspend()` + +Let the instance know you wanna suspend so it can make relevant changes. + +#### `await server.resume()` + +Let the instance know you wanna resume from suspension. Will rebind the server etc. + ## License Apache-2.0 diff --git a/index.js b/index.js index e02007b..f69418f 100644 --- a/index.js +++ b/index.js @@ -60,7 +60,7 @@ module.exports = class ServeBlobs { _onconnection (socket) { if (this.suspending) { - this.connection.destroy() + socket.destroy() return } @@ -193,6 +193,7 @@ module.exports = class ServeBlobs { async _resume () { if (this.suspending) await this.suspending + this.suspending = null if (this.server !== null) { await this._closeAll(true) this.server.ref() @@ -273,7 +274,7 @@ module.exports = class ServeBlobs { } = opts if (!blob && !filename) { - throw new Error('Must specific a filename or blob') + throw new Error('Must specify a filename or blob') } const p = (protocol === 'http' && port === 80) diff --git a/package.json b/package.json index a073912..fd2f35d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ } }, "scripts": { - "test": "standard" + "test": "standard && brittle test/*.js" }, "dependencies": { "bare-http1": "^4.0.2", @@ -30,6 +30,11 @@ "z32": "^1.1.0" }, "devDependencies": { + "brittle": "^3.7.0", + "corestore": "^6.18.4", + "hyperblobs": "^2.7.4", + "hyperdrive": "^11.13.3", + "random-access-memory": "^6.2.1", "standard": "^17.1.2" }, "repository": { diff --git a/test/blobs.js b/test/blobs.js new file mode 100644 index 0000000..305d91d --- /dev/null +++ b/test/blobs.js @@ -0,0 +1,113 @@ +const test = require('brittle') +const RAM = require('random-access-memory') +const Corestore = require('corestore') +const { testBlobServer, request, testHyperblobs } = require('./helpers') + +test('can get blob from hypercore', async function (t) { + const store = new Corestore(RAM) + + const blobs = testHyperblobs(t, store) + + const id = await blobs.put(Buffer.from('Hello World')) + + const server = testBlobServer(t, store) + await server.listen() + + const res = await request(server, blobs.core.key, { blob: id }) + + t.is(res.status, 200) + t.is(res.data, 'Hello World') +}) + +test('can get blob from hypercore - multiple blocks', async function (t) { + const store = new Corestore(RAM) + + const blobs = testHyperblobs(t, store) + blobs.blockSize = 4 // force multiple blocks + + const id = await blobs.put(Buffer.from('Hello World')) + t.is(id.blockLength, 3) // 3 blocks + + const server = testBlobServer(t, store) + await server.listen() + + const res = await request(server, blobs.core.key, { blob: id }) + + t.is(res.status, 200) + t.is(res.data, 'Hello World') +}) + +test('can get a partial blob from hypercore', async function (t) { + const store = new Corestore(RAM) + + const blobs = testHyperblobs(t, store) + + const id = await blobs.put(Buffer.from('Hello World')) + + const server = testBlobServer(t, store) + await server.listen() + + const res = await request(server, blobs.core.key, { blob: id, range: 'bytes=3-7' }) + t.is(res.status, 206) + t.is(res.data, 'lo Wo') +}) + +test('can get a partial blob from hypercore, but request the whole data', async function (t) { + const store = new Corestore(RAM) + + const blobs = testHyperblobs(t, store) + + const id = await blobs.put(Buffer.from('Hello World')) + + const server = testBlobServer(t, store) + await server.listen() + + const res = await request(server, blobs.core.key, { blob: id, range: 'bytes=0-10' }) + t.is(res.status, 206) + t.is(res.data, 'Hello World') +}) + +test('handle out of range header end', async function (t) { + const store = new Corestore(RAM) + + const blobs = testHyperblobs(t, store) + + const id = await blobs.put(Buffer.from('Hello World')) + + const server = testBlobServer(t, store) + await server.listen() + + const res = await request(server, blobs.core.key, { blob: id, range: 'bytes=0-20' }) + t.is(res.status, 206) + t.is(res.data, 'Hello World') +}) + +test('handle range header without end', async function (t) { + const store = new Corestore(RAM) + + const blobs = testHyperblobs(t, store) + + const id = await blobs.put(Buffer.from('Hello World')) + + const server = testBlobServer(t, store) + await server.listen() + + const res = await request(server, blobs.core.key, { blob: id, range: 'bytes=2-' }) + t.is(res.status, 206) + t.is(res.data, 'llo World') +}) + +test('handle invalid range header', async function (t) { + const store = new Corestore(RAM) + + const blobs = testHyperblobs(t, store) + + const id = await blobs.put(Buffer.from('Hello World')) + + const server = testBlobServer(t, store) + await server.listen() + + const res = await request(server, blobs.core.key, { blob: id, range: 'testing' }) + t.is(res.status, 200) + t.is(res.data, 'Hello World') +}) diff --git a/test/drives.js b/test/drives.js new file mode 100644 index 0000000..f329044 --- /dev/null +++ b/test/drives.js @@ -0,0 +1,84 @@ +const test = require('brittle') +const RAM = require('random-access-memory') +const Corestore = require('corestore') +const { testHyperdrive, testBlobServer, request, get } = require('./helpers') + +test('can get file from hyperdrive', async function (t) { + const store = new Corestore(RAM) + + const drive = testHyperdrive(t, store) + await drive.put('/file.txt', 'Here') + + const server = testBlobServer(t, store) + await server.listen() + + const res = await request(server, drive.key, { filename: '/file.txt' }) + t.is(res.status, 200) + t.is(res.data, 'Here') +}) + +test('404 if file not found', async function (t) { + const store = new Corestore(RAM) + + const drive = testHyperdrive(t, store) + await drive.put('/file.txt', 'Here') + + const server = testBlobServer(t, store) + await server.listen() + + const res = await request(server, drive.key, { filename: '/testing.txt' }) + t.is(res.status, 404) + t.is(res.data, '') +}) + +test('404 if token is invalid', async function (t) { + const store = new Corestore(RAM) + + const drive = testHyperdrive(t, store) + await drive.put('/file.txt', 'Here') + + const server = testBlobServer(t, store) + await server.listen() + + const link = server.getLink(drive.key, { filename: '/testing.txt' }) + const res = await get(link.replace('token=', 'token=breakme')) + + t.is(res.status, 404) + t.is(res.data, '') +}) + +test('sending request while suspended', async function (t) { + const store = new Corestore(RAM) + + const drive = testHyperdrive(t, store) + await drive.put('/file.txt', 'Here') + + const server = testBlobServer(t, store) + await server.listen() + + await server.suspend() + + try { + await request(server, drive.key, { filename: '/file.txt' }) + t.fail('request should fail') + } catch (err) { + t.ok(err) + } +}) + +test('sending request after resume', async function (t) { + const store = new Corestore(RAM) + + const drive = testHyperdrive(t, store) + await drive.put('/file.txt', 'Here') + + const server = testBlobServer(t, store) + await server.listen() + + await server.suspend() + await server.resume() + + const res = await request(server, drive.key, { filename: '/file.txt' }) + t.is(res.status, 200) + t.is(res.data, 'Here') +}) diff --git a/test/helpers/index.js b/test/helpers/index.js new file mode 100644 index 0000000..7ef0e25 --- /dev/null +++ b/test/helpers/index.js @@ -0,0 +1,68 @@ +const http = require('http') +const BlobServer = require('../../index.js') +const Hyperdrive = require('hyperdrive') +const Hyperblobs = require('hyperblobs') + +module.exports = { + request, + get, + testBlobServer, + testHyperblobs, + testHyperdrive +} + +function get (link, range = null) { + return new Promise((resolve, reject) => { + const req = http.get(link, { + headers: { + Connection: 'close', + range + } + }) + + req.on('error', reject) + req.on('response', function (res) { + if (res.statusCode === 307) { + // follow redirect + get(new URL(link).origin + res.headers.location).then(resolve).catch(reject) + } else { + let buf = '' + res.setEncoding('utf-8') + res.on('data', function (data) { + buf += data + }) + res.on('end', function () { + resolve({ status: res.statusCode, data: buf }) + }) + res.on('close', function () { + resolve({ status: res.statusCode, data: buf }) + }) + } + }) + }) +} + +async function request (server, key, opts) { + const link = server.getLink(key, opts) + + return get(link, opts.range) +} + +function testBlobServer (t, store, opts) { + const server = new BlobServer(store, opts) + t.teardown(() => server.close()) + return server +} + +function testHyperblobs (t, store) { + const core = store.get({ name: 'test' }) + const blobs = new Hyperblobs(core) + t.teardown(() => blobs.close()) + return blobs +} + +function testHyperdrive (t, store) { + const drive = new Hyperdrive(store) + t.teardown(() => drive.close()) + return drive +}