UNPKG

@fnlb-project/stanza

Version:

Modern XMPP in the browser, with a JSON API

277 lines (276 loc) 10.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Receiver = exports.Sender = void 0; const tslib_1 = require("tslib"); const events_1 = require("events"); const Hashes = tslib_1.__importStar(require("../platform")); const Constants_1 = require("../Constants"); const Namespaces_1 = require("../Namespaces"); const ICESession_1 = tslib_1.__importDefault(require("./ICESession")); const Intermediate_1 = require("./sdp/Intermediate"); const Protocol_1 = require("./sdp/Protocol"); class Sender extends events_1.EventEmitter { constructor(opts = {}) { super(); this.config = { chunkSize: 16384, hash: 'sha-1', ...opts }; this.file = undefined; this.channel = undefined; this.hash = Hashes.createHash(this.config.hash); } send(file, channel) { if (this.file && this.channel) { return; } this.file = file; this.channel = channel; this.channel.binaryType = 'arraybuffer'; const fileReader = new FileReader(); let offset = 0; let pendingRead = false; const sliceFile = () => { if (pendingRead || offset >= file.size) { return; } pendingRead = true; const slice = file.slice(offset, offset + this.config.chunkSize); fileReader.readAsArrayBuffer(slice); }; channel.bufferedAmountLowThreshold = 8 * this.config.chunkSize; channel.onbufferedamountlow = () => { sliceFile(); }; fileReader.addEventListener('load', (event) => { const data = event.target.result; pendingRead = false; offset += data.byteLength; this.channel.send(data); this.hash.update(new Uint8Array(data)); this.emit('progress', offset, file.size, data); if (offset < file.size) { if (this.channel.bufferedAmount <= this.channel.bufferedAmountLowThreshold) { sliceFile(); } // Otherwise wait for bufferedamountlow event to trigger reading more data } else { this.emit('progress', file.size, file.size, null); this.emit('sentFile', { algorithm: this.config.hash, name: file.name, size: file.size, value: this.hash.digest() }); } }); sliceFile(); } } exports.Sender = Sender; class Receiver extends events_1.EventEmitter { constructor(opts = {}) { super(); this.config = { hash: 'sha-1', ...opts }; this.receiveBuffer = []; this.received = 0; this.channel = undefined; this.hash = Hashes.createHash(this.config.hash); } receive(metadata, channel) { this.metadata = metadata; this.channel = channel; this.channel.binaryType = 'arraybuffer'; this.channel.onmessage = e => { const len = e.data.byteLength; this.received += len; this.receiveBuffer.push(e.data); if (e.data) { this.hash.update(new Uint8Array(e.data)); } this.emit('progress', this.received, this.metadata.size, e.data); if (this.received === this.metadata.size) { this.metadata.actualhash = this.hash.digest('hex'); this.emit('receivedFile', new Blob(this.receiveBuffer), this.metadata); this.receiveBuffer = []; } else if (this.received > this.metadata.size) { // FIXME console.error('received more than expected, discarding...'); this.receiveBuffer = []; // just discard... } }; } } exports.Receiver = Receiver; class FileTransferSession extends ICESession_1.default { constructor(opts) { super(opts); this.sender = undefined; this.receiver = undefined; this.file = undefined; } async start(file, next) { next = next || (() => undefined); if (!file || typeof file === 'function') { throw new Error('File object required'); } this.state = 'pending'; this.role = 'initiator'; this.file = file; this.sender = new Sender(); this.sender.on('progress', (sent, size) => { this._log('info', 'Send progress ' + sent + '/' + size); }); this.sender.on('sentFile', meta => { this._log('info', 'Sent file', meta.name); this.send(Constants_1.JingleAction.SessionInfo, { info: { creator: Constants_1.JingleSessionRole.Initiator, file: { hashes: [ { algorithm: meta.algorithm, value: meta.value } ] }, infoType: Constants_1.JINGLE_INFO_CHECKSUM_5, name: this.contentName } }); this.parent.emit('sentFile', this, meta); }); this.channel = this.pc.createDataChannel('filetransfer', { ordered: true }); this.channel.onopen = () => { this.sender.send(this.file, this.channel); }; try { await this.processLocal(Constants_1.JingleAction.SessionInitiate, async () => { const offer = await this.pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false }); const json = (0, Intermediate_1.importFromSDP)(offer.sdp); const jingle = (0, Protocol_1.convertIntermediateToRequest)(json, this.role, this.transportType); this.contentName = jingle.contents[0].name; jingle.sid = this.sid; jingle.action = Constants_1.JingleAction.SessionInitiate; jingle.contents[0].application = { applicationType: Namespaces_1.NS_JINGLE_FILE_TRANSFER_5, file: { date: file.lastModified ? new Date(file.lastModified) : undefined, hashesUsed: [ { algorithm: 'sha-1' } ], name: file.name, size: file.size } }; this.send('session-initiate', jingle); await this.pc.setLocalDescription(offer); }); next(); } catch (err) { this._log('error', 'Could not create WebRTC offer', err); return this.end('failed-application', true); } } async accept(next) { this._log('info', 'Accepted incoming session'); this.role = 'responder'; this.state = 'active'; next = next || (() => undefined); try { await this.processLocal(Constants_1.JingleAction.SessionAccept, async () => { const answer = await this.pc.createAnswer(); const json = (0, Intermediate_1.importFromSDP)(answer.sdp); const jingle = (0, Protocol_1.convertIntermediateToRequest)(json, this.role, this.transportType); jingle.sid = this.sid; jingle.action = 'session-accept'; for (const content of jingle.contents) { content.creator = 'initiator'; } this.contentName = jingle.contents[0].name; this.send('session-accept', jingle); await this.pc.setLocalDescription(answer); await this.processBufferedCandidates(); }); next(); } catch (err) { this._log('error', 'Could not create WebRTC answer', err); this.end('failed-application'); } } async onSessionInitiate(changes, cb) { this._log('info', 'Initiating incoming session'); this.role = 'responder'; this.state = 'pending'; this.transportType = changes.contents[0].transport.transportType; const json = (0, Protocol_1.convertRequestToIntermediate)(changes, this.peerRole); const sdp = (0, Intermediate_1.exportToSDP)(json); const desc = changes.contents[0].application; const hashes = desc.file.hashesUsed ? desc.file.hashesUsed : desc.file.hashes || []; this.receiver = new Receiver({ hash: hashes[0] && hashes[0].algorithm }); this.receiver.on('progress', (received, size) => { this._log('info', 'Receive progress ' + received + '/' + size); }); this.receiver.on('receivedFile', file => { this.receivedFile = file; this._maybeReceivedFile(); }); this.receiver.metadata = desc.file; this.pc.addEventListener('datachannel', e => { this.channel = e.channel; this.receiver.receive(this.receiver.metadata, e.channel); }); try { await this.pc.setRemoteDescription({ type: 'offer', sdp }); await this.processBufferedCandidates(); cb(); } catch (err) { this._log('error', 'Could not create WebRTC answer', err); cb({ condition: 'general-error' }); } } onSessionInfo(changes, cb) { const info = changes.info; if (!info || !info.file || !info.file.hashes) { return; } this.receiver.metadata.hashes = info.file.hashes; if (this.receiver.metadata.actualhash) { this._maybeReceivedFile(); } cb(); } _maybeReceivedFile() { if (!this.receiver.metadata.hashes || !this.receiver.metadata.hashes.length) { // unknown hash, file transfer not completed return; } for (const hash of this.receiver.metadata.hashes || []) { if (hash.value && hash.value.toString('hex') === this.receiver.metadata.actualhash) { this._log('info', 'File hash matches'); this.parent.emit('receivedFile', this, this.receivedFile, this.receiver.metadata); this.end('success'); return; } } this._log('error', 'File hash does not match'); this.end('media-error'); } } exports.default = FileTransferSession;