diff --git a/.github/workflows/test-node.yml b/.github/workflows/test-node.yml new file mode 100644 index 0000000..e12ceb1 --- /dev/null +++ b/.github/workflows/test-node.yml @@ -0,0 +1,23 @@ +name: Build Status +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + build: + strategy: + matrix: + node-version: [lts/*] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a1dad77 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Lucas Barrena + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a0b69f --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# hyper-webrtc + +WebRTC tools for the hypercore-protocol stack + +``` +npm i hyper-webrtc +``` + +Warning: This is experimental, and API might easily change on next versions. + +## Usage + +```js +const HyperWebRTC = require('hyper-webrtc') +``` + +## License + +MIT diff --git a/index.js b/index.js new file mode 100644 index 0000000..332655f --- /dev/null +++ b/index.js @@ -0,0 +1,243 @@ +const { Peer } = require('peerjs') // => STUN/TURN + RTCPeerConnection +const Protomux = require('protomux') +const c = require('compact-encoding') +const safetyCatch = require('safety-catch') +const ReadyResource = require('ready-resource') +const sodium = require('sodium-universal') +const b4a = require('b4a') +const WebStream = require('./lib/web-stream.js') + +// TODO: This could be a Duplex but don't know about that, for now emitting an event is good enough +module.exports = class WebPeer extends ReadyResource { + constructor (stream) { + super() + + const id = b4a.toString(randomBytes(8), 'hex') + + // this.peer = new Peer(id) + this.peer = new Peer() + this.stream = stream + // this.mux = new Protomux(stream) + this.channel = null + + this.handshake = null + this.token = b4a.toString(randomBytes(8), 'hex') // TODO: Think another solution for validating connections + this.remote = null + + this.ready().catch(safetyCatch) + } + + async _open () { + // TODO: Investigate about reusing the relayed handshake to create new SecretStream instances + /* const onhandshake = () => this.handshake = getHandshake(this.stream) + if (this.stream.handshakeHash) onhandshake() // Or this.stream._encrypt + else this.stream.once('handshake', onhandshake) */ + + this.peer.on('connection', rawStream => { + console.log('peerjs incoming', rawStream) + + // TODO: Check metadata.token before accepting the connection + + rawStream.on('open', () => { + console.log('rawStream open') + + // if (!this.handshake) throw new Error('No handshake') + + const duplex = new WebStream(rawStream) + + this.remote = duplex // new SecretStream(false, duplex) + // this.remote.on('data', (data) => console.log(data)) + // this.remote.on('close', () => console.log('remote closed')) + this.remote.on('error', console.error) + + // const done = () => this.mux.destroy() + // waitForRemote(this.remote).then(done, done) + + this.emit('continue', this.remote) + }) + + rawStream.on('error', function (err) { + console.log('rawStream error', err) + }) + + rawStream.on('close', function () { + console.log('rawStream close') + }) + }) + + try { + await waitForPeer(this.peer) + } catch (err) { + console.error(err) + this.stream.destroy() + throw err + } + + this.mux = new Protomux(this.stream) + + this._attachChannel() + + console.log('peer.id', this.peer.id) + + this.channel.open({ + // isInitiator: this.mux.stream.isInitiator, + id: this.peer.id, + token: this.token + }) + } + + _close () { + // this.peer.destroy() + // if (this.mux) this.mux.destroy() + this.stream.destroy() + } + + _attachChannel () { + const channel = this.mux.createChannel({ + protocol: 'hyperconnection', + id: null, + handshake: c.json, // TODO: Make strict messages + onopen: this._onmuxopen.bind(this), + onerror: this._onmuxerror.bind(this), + onclose: this._onmuxclose.bind(this), + messages: [ + // { encoding: c.json, onmessage: this._onmuxmessage } + ] + }) + + if (channel === null) return + + this.channel = channel + } + + _onmuxopen (handshake) { + console.log('_onmuxopen', handshake) + + if (this.mux.stream.isInitiator) { + console.log('Connecting to', handshake.id) + + // TODO: Investigate if metadata is kept truly private between both peers (E2E encrypted, not publicly stored in the middle server, etc) + const rawStream = this.peer.connect(handshake.id, { + reliable: true, + /* metadata: { + token: this.token + } */ + }) + + rawStream.on('open', () => { + console.log('rawStream open') + + // if (!this.handshake) throw new Error('No handshake') + + const duplex = new WebStream(rawStream) + + this.remote = duplex // new SecretStream(true, duplex) + + /* this.remote.on('connect', () => { + console.log('remote connected') + }) + + this.remote.on('open', () => { + console.log('remote opened') + }) */ + + this.remote.on('error', console.error) + + // TODO: Can destroy it right away? + // const done = () => this.mux.destroy() + // waitForRemote(this.remote).then(done, done) + + this.emit('continue', this.remote) + }) + + rawStream.on('error', function (err) { + console.log('rawStream error', err) + }) + + rawStream.on('close', function () { + console.log('rawStream close') + }) + } + } + + _onmuxerror (err) { + console.error('_onmuxerror', err) + } + + _onmuxclose (isRemote) { + console.log('_onmuxclose', { isRemote }, 'Stream created?', !!this.remote) + + // if (!this.remote) this.peer.destroy() + // this.mux.destroy() + } +} + +/* function getHandshake (stream) { + return { + publicKey: stream.publicKey, + remotePublicKey: stream.remotePublicKey, + hash: stream.handshakeHash, + tx: stream.tx || stream._encrypt?.key || null, + rx: stream.rx || stream._decrypt?.key || null + } +} */ + +function waitForRemote (remote) { + return new Promise(resolve => { + this.remote.on('open', done) + this.remote.on('open', done) + this.remote.on('error', done) + this.remote.on('close', done) + + function done () { + this.remote.off('open', done) + this.remote.off('error', done) + this.remote.off('close', done) + + resolve() + } + }) +} + +// TODO: Simplify a bit +function waitForPeer (peer) { + return new Promise((resolve, reject) => { + if (peer.disconnected === true) { + reject(new Error('Peer is disconnected')) + return + } + + if (peer.destroyed) { + reject(new Error('Peer is destroyed')) + return + } + + peer.on('open', onopen) + peer.on('error', onclose) + peer.on('close', onclose) + + function onopen (id) { + cleanup() + resolve() + } + + function onclose (err) { + cleanup() + + if (err) reject(err) + else reject(new Error('Could not create peer')) + } + + function cleanup () { + peer.off('open', onopen) + peer.off('error', onclose) + peer.off('close', onclose) + } + }) +} + +function randomBytes (n) { + const buf = b4a.allocUnsafe(n) + sodium.randombytes_buf(buf) + return buf +} diff --git a/index2.js b/index2.js new file mode 100644 index 0000000..7f63bcb --- /dev/null +++ b/index2.js @@ -0,0 +1,80 @@ +const iceServers = [ + { urls: 'stun:stun.l.google.com:19302' } +] + +let $id = 0 + +module.exports = class LikeWebRTC { + constructor () { + this._id = $id++ + + this.peer = new RTCPeerConnection({ iceServers }) + + this._setup() + } + + _setup () { + this.peer.onicecandidate = (event) => { + if (event.candidate) { + console.log(this._id, 'onicecandidate', event) + this.onice(event) + } + } + } + + addIceCandidate (candidate) { + this.peer.addIceCandidate(new RTCIceCandidate(candidate)) + } + + onice (event) {} + + // + + // connect () {} + + // + + createChannel (name) { + const channel = this.peer.createDataChannel(name) + // const channel = this.peer.createDataChannel(name, { negotiated: true, id: 0 }) // ondatachannel will not fire + + channel.onopen = () => { + console.log('Data channel opened'); + channel.send('Hello, peer!'); + }; + + channel.onclose = () => { + console.log('data channel closed') + } + + channel.onerror = (err) => console.error(err) + + channel.onmessage = (event) => { + console.log('Received message:', event.data); + }; + + return channel + } + + async createOffer () { + const offer = await this.peer.createOffer() + + await this.peer.setLocalDescription(offer) + + return this.peer.localDescription // => offer + } + + async receiveOffer (offer) { + this.peer.setRemoteDescription(new RTCSessionDescription(offer)) + + const answer = await this.peer.createAnswer() + + await this.peer.setLocalDescription(answer) + + return this.peer.localDescription // => answer + } + + async receiveAnswer (answer) { + this.peer.setRemoteDescription(new RTCSessionDescription(answer)) + } +} diff --git a/lib/web-stream.js b/lib/web-stream.js new file mode 100644 index 0000000..c68123b --- /dev/null +++ b/lib/web-stream.js @@ -0,0 +1,60 @@ +const b4a = require('b4a') +const { Duplex } = require('streamx') + +module.exports = class WebStream extends Duplex { + constructor (rtc) { + super({ mapWritable: toBuffer }) + + this.rtc = rtc + this.noiseStream = this + this.rawStream = this + + this._openedDone = null + + this.opened = new Promise(resolve => this._openedDone = resolve) + + this._setup() + + this.resume().pause() // Open immediately + } + + _setup () { + this.rtc.on('data', data => { + this.push(new Uint8Array(data)) + }) + + this.rtc.on('close', () => { + this.push(null) + this.emit('close') + }) + + this.rtc.on('error', (err) => { + this.emit('error', err) + }) + + this._openedDone(true) // TODO + } + + _open (cb) { + cb(null) + } + + _read (cb) { + cb(null) + } + + _write (chunk, cb) { + this.rtc.send(chunk) + cb(null) + } + + _destroy () { + this.rtc.close() + } + + setKeepAlive () {} // TODO +} + +function toBuffer (data) { + return typeof data === 'string' ? b4a.from(data) : data +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1acb0b7 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "hyper-webrtc", + "version": "0.0.0", + "description": "WebRTC tools for the hypercore-protocol stack", + "main": "index.js", + "scripts": { + "test": "standard" + }, + "repository": { + "type": "git", + "url": "https://github.com/LuKks/hyper-webrtc.git" + }, + "author": "Lucas Barrena (LuKks)", + "license": "MIT", + "bugs": { + "url": "https://github.com/LuKks/hyper-webrtc/issues" + }, + "homepage": "https://github.com/LuKks/hyper-webrtc#readme", + "devDependencies": { + "brittle": "^3.3.2", + "standard": "^17.1.0" + }, + "dependencies": { + "@hyperswarm/dht-relay": "^0.4.3", + "b4a": "^1.6.4", + "compact-encoding": "^2.13.0", + "hypercore": "^10.32.5", + "hypercore-crypto": "^3.4.0", + "hyperswarm": "^4.7.13", + "peerjs": "^1.5.2", + "protomux": "^3.5.1", + "random-access-memory": "^6.2.0", + "sodium-universal": "^4.0.0", + "ws": "^8.16.0" + } +} diff --git a/test2.js b/test2.js new file mode 100644 index 0000000..d960702 --- /dev/null +++ b/test2.js @@ -0,0 +1,40 @@ +const test = require('brittle') +const WebRTC = require('./index2.js') + +test('basic', async function (t) { + const a = new WebRTC() + const b = new WebRTC() + + a.onice = function (e) { + b.addIceCandidate(e.candidate) + } + + b.onice = function (e) { + a.addIceCandidate(e.candidate) + } + + const channel1 = a.createChannel('rohil-is-he-knows') + b.peer.ondatachannel = function (e) { + const channel2 = e.channel + + channel2.onopen = () => { + console.log('Data channel opened'); + }; + + channel2.onclose = () => { + console.log('data channel closed') + } + + channel2.onerror = (err) => console.error(err) + + channel2.onmessage = (event) => { + console.log('Received message:', event.data); + }; + } + + const offer = await a.createOffer() + const answer = await b.receiveOffer(offer) + await a.receiveAnswer(answer) + + +})