/* * Copyright (C) 2020 Yomichan Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ class CrossFrameAPIPort extends EventDispatcher { constructor(otherFrameId, port, messageHandlers) { super(); this._otherFrameId = otherFrameId; this._port = port; this._messageHandlers = messageHandlers; this._activeInvocations = new Map(); this._invocationId = 0; this._eventListeners = new EventListenerCollection(); } get otherFrameId() { return this._otherFrameId; } prepare() { this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this)); this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this)); } invoke(action, params, ackTimeout, responseTimeout) { return new Promise((resolve, reject) => { if (this._port === null) { reject(new Error('Port is disconnected')); return; } const id = this._invocationId++; const invocation = {id, resolve, reject, responseTimeout, ack: false, timer: null}; this._activeInvocations.set(id, invocation); if (ackTimeout !== null) { try { invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (ack)')), ackTimeout); } catch (e) { this._onError(id, new Error('Failed to set timeout')); return; } } try { this._port.postMessage({type: 'invoke', id, data: {action, params}}); } catch (e) { this._onError(id, e); } }); } disconnect() { this._onDisconnect(); } // Private _onDisconnect() { if (this._port === null) { return; } this._eventListeners.removeAllEventListeners(); this._port = null; for (const id of this._activeInvocations.keys()) { this._onError(id, new Error('Disconnected')); } this.trigger('disconnect', this); } _onMessage({type, id, data}) { switch (type) { case 'invoke': this._onInvoke(id, data); break; case 'ack': this._onAck(id); break; case 'result': this._onResult(id, data); break; } } // Response handlers _onAck(id) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { yomichan.logWarning(new Error(`Request ${id} not found for ack`)); return; } if (invocation.ack) { this._onError(id, new Error(`Request ${id} already ack'd`)); return; } invocation.ack = true; if (invocation.timer !== null) { clearTimeout(invocation.timer); invocation.timer = null; } const responseTimeout = invocation.responseTimeout; if (responseTimeout !== null) { try { invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (response)')), responseTimeout); } catch (e) { this._onError(id, new Error('Failed to set timeout')); } } } _onResult(id, data) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { yomichan.logWarning(new Error(`Request ${id} not found`)); return; } if (!invocation.ack) { this._onError(id, new Error(`Request ${id} not ack'd`)); return; } this._activeInvocations.delete(id); if (invocation.timer !== null) { clearTimeout(invocation.timer); invocation.timer = null; } const error = data.error; if (typeof error !== 'undefined') { invocation.reject(jsonToError(error)); } else { invocation.resolve(data.result); } } _onError(id, error) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { return; } this._activeInvocations.delete(id); if (invocation.timer !== null) { clearTimeout(invocation.timer); invocation.timer = null; } invocation.reject(error); } // Invocation _onInvoke(id, {action, params}) { const messageHandler = this._messageHandlers.get(action); if (typeof messageHandler === 'undefined') { this._sendError(id, new Error(`Unknown action: ${action}`)); return; } const {handler, async} = messageHandler; this._sendAck(id); if (async) { this._invokeHandlerAsync(id, handler, params); } else { this._invokeHandler(id, handler, params); } } _invokeHandler(id, handler, params) { try { const result = handler(params); this._sendResult(id, result); } catch (error) { this._sendError(id, error); } } async _invokeHandlerAsync(id, handler, params) { try { const result = await handler(params); this._sendResult(id, result); } catch (error) { this._sendError(id, error); } } _sendResponse(data) { if (this._port === null) { return; } try { this._port.postMessage(data); } catch (e) { // NOP } } _sendAck(id) { this._sendResponse({type: 'ack', id}); } _sendResult(id, result) { this._sendResponse({type: 'result', id, data: {result}}); } _sendError(id, error) { this._sendResponse({type: 'result', id, data: {error: errorToJson(error)}}); } } class CrossFrameAPI { constructor() { this._ackTimeout = 3000; // 3 seconds this._responseTimeout = 10000; // 10 seconds this._commPorts = new Map(); this._messageHandlers = new Map(); this._onDisconnectBind = this._onDisconnect.bind(this); } prepare() { chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); } async invoke(targetFrameId, action, params={}) { const commPort = this._getOrCreateCommPort(targetFrameId); return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout); } registerHandlers(messageHandlers) { for (const [key, value] of messageHandlers) { if (this._messageHandlers.has(key)) { throw new Error(`Handler ${key} is already registered`); } this._messageHandlers.set(key, value); } } _onConnect(port) { const match = /^cross-frame-communication-port-(\d+)$/.exec(`${port.name}`); if (match === null) { return; } const otherFrameId = parseInt(match[1], 10); this._setupCommPort(otherFrameId, port); } _onDisconnect(commPort) { commPort.off('disconnect', this._onDisconnectBind); this._commPorts.delete(commPort.otherFrameId); } _getOrCreateCommPort(otherFrameId) { const commPort = this._commPorts.get(otherFrameId); return (typeof commPort !== 'undefined' ? commPort : this._createCommPort(otherFrameId)); } _createCommPort(otherFrameId) { const port = chrome.runtime.connect(null, {name: `background-cross-frame-communication-port-${otherFrameId}`}); return this._setupCommPort(otherFrameId, port); } _setupCommPort(otherFrameId, port) { const commPort = new CrossFrameAPIPort(otherFrameId, port, this._messageHandlers); this._commPorts.set(otherFrameId, commPort); commPort.prepare(); commPort.on('disconnect', this._onDisconnectBind); return commPort; } }