"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const isTransferableObject_1 = require("./utils/isTransferableObject");
const mergeCallbacksAndSerializable_1 = require("./utils/mergeCallbacksAndSerializable");
const separateCallbacksFromSerializable_1 = require("./utils/separateCallbacksFromSerializable");
const separateCallbacksFromSerializableArray_1 = require("./utils/separateCallbacksFromSerializableArray");
var MessageType;
(function (MessageType) {
    MessageType["RemoteReady"] = "RemoteReady";
    MessageType["InvokeMethod"] = "InvokeMethod";
    MessageType["ErrorResponse"] = "ErrorResponse";
    MessageType["ReturnValueResponse"] = "ReturnValueResponse";
    MessageType["CloseChannel"] = "CloseChannel";
    MessageType["OnCallbackDropped"] = "OnCallbackDropped";
})(MessageType || (MessageType = {}));
// A thin wrapper around postMessage. A script within `targetWindow` should
// also construct a RemoteMessenger (with IncomingMessageType and
// OutgoingMessageType reversed).
class RemoteMessenger {
    // channelId should be the same as the id of the messenger this will communicate with.
    //
    // If localInterface is null, .setLocalInterface must be called.
    // This allows chaining multiple messengers together.
    constructor(channelId, localInterface) {
        this.channelId = channelId;
        this.localInterface = localInterface;
        this.resolveMethodCallbacks = Object.create(null);
        this.rejectMethodCallbacks = Object.create(null);
        this.argumentCallbacks = new Map();
        this.callbackTracker = undefined;
        this.numberUnrespondedToMethods = 0;
        this.noWaitingMethodsListeners = [];
        this.remoteReadyListeners = [];
        this.isRemoteReady = false;
        this.isLocalReady = false;
        this.nextResponseId = 0;
        this.closed = false;
        // If true, we'll be ready to receive data after .setLocalInterface is next called.
        this.waitingForLocalInterface = false;
        // True if remoteApi methods should be called with `.apply(thisVariable, ...)` to preserve
        // the value of `this`.
        // Having `preserveThis` set to `true` may be problematic if chaining messengers. If chaining,
        // set `preserveThis` to false.
        this.preserveThis = true;
        this.lastCallbackDropTime_ = 0;
        this.bufferedDroppedCallbackIds_ = [];
        this.onInvokeCallback = (callbackId, callbackArgs) => {
            return this.invokeRemoteMethod(['__callbacks', callbackId], callbackArgs);
        };
        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
        this.trackCallbackFinalization = (callbackId, callback) => {
            var _a;
            (_a = this.callbackTracker) === null || _a === void 0 ? void 0 : _a.register(callback, callbackId);
        };
        const makeApiFor = (methodPath) => {
            // Use a function as the base object so that .apply works.
            const baseObject = () => { };
            return new Proxy(baseObject, {
                // Map all properties to functions that invoke remote
                // methods.
                // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
                get: (_target, property) => {
                    if (property === '___is_joplin_wrapper___') {
                        return true;
                    }
                    else {
                        return makeApiFor([...methodPath, property]);
                    }
                },
                apply: (_target, _thisArg, argumentsList) => {
                    return this.invokeRemoteMethod(methodPath, argumentsList);
                },
            });
        };
        this.remoteApi = makeApiFor([]);
        if (typeof FinalizationRegistry !== 'undefined') {
            // Creating a FinalizationRegistry allows us to track **local** deletions of callbacks.
            // We can then inform the remote so that it can free the corresponding remote callback.
            this.callbackTracker = new FinalizationRegistry((callbackId) => {
                this.dropRemoteCallback_(callbackId);
            });
        }
    }
    createResponseId(methodPath) {
        return `${methodPath.join(',')}-${this.nextResponseId++}`;
    }
    registerCallbacks(idToCallbacks) {
        for (const id in idToCallbacks) {
            this.argumentCallbacks.set(id, idToCallbacks[id]);
        }
    }
    // protected: For testing
    dropRemoteCallback_(callbackId) {
        this.bufferedDroppedCallbackIds_.push(callbackId);
        if (!this.isRemoteReady)
            return;
        // Don't send too many messages. On mobile platforms, each
        // message has overhead and .dropRemoteCallback is called
        // frequently.
        if (Date.now() - this.lastCallbackDropTime_ < 10000)
            return;
        this.postMessage({
            kind: MessageType.OnCallbackDropped,
            callbackIds: this.bufferedDroppedCallbackIds_,
            channelId: this.channelId,
        });
        this.bufferedDroppedCallbackIds_ = [];
        this.lastCallbackDropTime_ = Date.now();
    }
    async invokeRemoteMethod(methodPath, args) {
        // Function arguments can't be transferred using standard .postMessage calls.
        // As such, we assign them IDs and transfer the IDs instead:
        const separatedArgs = (0, separateCallbacksFromSerializableArray_1.default)(args);
        this.registerCallbacks(separatedArgs.idToCallbacks);
        // Wait for the remote to be ready to receive before
        // actually sending a message.
        this.numberUnrespondedToMethods++;
        await this.awaitRemoteReady();
        return new Promise((resolve, reject) => {
            const responseId = this.createResponseId(methodPath);
            this.resolveMethodCallbacks[responseId] = returnValue => {
                resolve(returnValue);
            };
            this.rejectMethodCallbacks[responseId] = (errorMessage) => {
                reject(errorMessage);
            };
            this.postMessage({
                kind: MessageType.InvokeMethod,
                methodPath,
                arguments: {
                    serializable: separatedArgs.serializableData,
                    callbacks: separatedArgs.callbacks,
                },
                respondWithId: responseId,
                channelId: this.channelId,
            });
        });
    }
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
    canRemoteAccessProperty(parentObject, methodName) {
        // TODO: There may be a better way to do this -- this currently assumes that
        //       **only** the following property names should be avoided.
        // The goal here is primarily to prevent remote from accessing the Function
        // constructor (which can lead to XSS).
        const isSafeMethodName = !['constructor', 'prototype', '__proto__'].includes(methodName);
        if (!isSafeMethodName) {
            return false;
        }
        // Function.constructor can be used to eval code. Avoid it.
        if (parentObject[methodName] === Function.constructor) {
            return false;
        }
        return true;
    }
    // Calls a local method and sends the result to the remote connection.
    async invokeLocalMethod(message) {
        var _a, _b;
        try {
            const methodFromPath = (path) => {
                // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
                const parentObjectStack = [];
                // We also use invokeLocalMethod to call callbacks that were previously
                // passed as arguments. In this case, path should be [ '__callbacks', callbackIdHere ].
                if (path.length === 2 && path[0] === '__callbacks' && this.argumentCallbacks.has(path[1])) {
                    return {
                        parentObject: undefined,
                        parentObjectStack,
                        method: this.argumentCallbacks.get(path[1]),
                    };
                }
                // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
                let parentObject;
                // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
                let currentObject = this.localInterface;
                for (let i = 0; i < path.length; i++) {
                    const propertyName = path[i];
                    if (!this.canRemoteAccessProperty(currentObject, propertyName)) {
                        throw new Error(`Cannot access property ${propertyName}`);
                    }
                    if (!currentObject[propertyName]) {
                        const accessPath = path.map(part => `[${JSON.stringify(part)}]`).join('');
                        throw new Error(`No such property ${JSON.stringify(propertyName)} on ${this.localInterface}. Accessing properties remoteApi${accessPath}.`);
                    }
                    parentObject = currentObject;
                    parentObjectStack.push(parentObject);
                    currentObject = currentObject[propertyName];
                }
                return { parentObject, parentObjectStack, method: currentObject };
            };
            const { method, parentObject, parentObjectStack } = methodFromPath(message.methodPath);
            if (typeof method !== 'function') {
                throw new Error(`Property ${message.methodPath.join('.')} is not a function.`);
            }
            const args = (0, mergeCallbacksAndSerializable_1.default)(message.arguments.serializable, message.arguments.callbacks, this.onInvokeCallback, this.trackCallbackFinalization);
            let result;
            if (this.preserveThis) {
                const lastMethodCallName = message.methodPath[message.methodPath.length - 1];
                const parentHasParent = parentObjectStack.length >= 2;
                // We need extra logic if the user is trying to .apply or .call a function.
                //
                // Specifically, if the user calls
                //     foo.apply(newThis, [some, args, here])
                // we want to remove the `.apply` to ensure that `foo` has the correct `this`
                // variable (and not some proxy object).
                //
                // We support this partially because TypeScript can generate .call or .apply
                // when converting to ES5.
                if (parentHasParent
                    && ['call', 'apply'].includes(lastMethodCallName)
                    && typeof parentObject === 'function') {
                    let adjustedArgs = args;
                    // Select [argsHere] from `.apply(newThis, [argsHere])`
                    if (lastMethodCallName === 'apply' && Array.isArray(args[1])) {
                        adjustedArgs = args[1];
                    }
                    else if (lastMethodCallName === 'call') {
                        // Otherwise, we remove the `this` variable from `.call(newThis, args, go, here, ...)`.
                        adjustedArgs = args.slice(1);
                    }
                    const newMethod = parentObject;
                    const newParent = parentObjectStack[parentObjectStack.length - 2];
                    if (typeof newMethod !== 'function') {
                        throw new Error(`RemoteMessenger(${this.channelId}, ${message.methodPath}): Attempting to call a non-function`);
                    }
                    result = await newMethod.apply(newParent, adjustedArgs);
                }
                else {
                    result = await method.apply(parentObject, args);
                }
            }
            else {
                result = await method(...args);
            }
            const separatedResult = (0, separateCallbacksFromSerializable_1.default)(result);
            this.registerCallbacks(separatedResult.idToCallbacks);
            this.postMessage({
                kind: MessageType.ReturnValueResponse,
                responseId: message.respondWithId,
                returnValue: {
                    serializable: separatedResult.serializableData,
                    callbacks: separatedResult.callbacks,
                },
                channelId: this.channelId,
            });
        }
        catch (error) {
            console.error('Error:', `(in RemoteMessenger, calling ${message === null || message === void 0 ? void 0 : message.methodPath}): `, error, error.stack, JSON.stringify(message));
            this.postMessage({
                kind: MessageType.ErrorResponse,
                responseId: message.respondWithId,
                errorMessage: `${error} (Calling ${(_b = (_a = message === null || message === void 0 ? void 0 : message.methodPath) === null || _a === void 0 ? void 0 : _a.join) === null || _b === void 0 ? void 0 : _b.call(_a, '.')})`,
                channelId: this.channelId,
            });
        }
    }
    onMethodRespondedTo(responseId) {
        delete this.resolveMethodCallbacks[responseId];
        delete this.rejectMethodCallbacks[responseId];
        this.numberUnrespondedToMethods--;
        if (this.numberUnrespondedToMethods === 0) {
            for (const listener of this.noWaitingMethodsListeners) {
                listener();
            }
            this.noWaitingMethodsListeners = [];
        }
        else if (this.numberUnrespondedToMethods < 0) {
            this.numberUnrespondedToMethods = 0;
            throw new Error('Some method has been responded to multiple times');
        }
    }
    async onRemoteResolve(message) {
        if (!this.resolveMethodCallbacks[message.responseId]) {
            // Debugging:
            //   throw new Error(`RemoteMessenger(${this.channelId}): Missing method callback with ID ${message.responseId}`);
            // This can happen if a promise is resolved multiple times.
            return;
        }
        const returnValue = (0, mergeCallbacksAndSerializable_1.default)(message.returnValue.serializable, message.returnValue.callbacks, this.onInvokeCallback, this.trackCallbackFinalization);
        this.resolveMethodCallbacks[message.responseId](returnValue);
        this.onMethodRespondedTo(message.responseId);
    }
    async onRemoteReject(message) {
        this.rejectMethodCallbacks[message.responseId](message.errorMessage);
        this.onMethodRespondedTo(message.responseId);
    }
    async onRemoteCallbackDropped(message) {
        for (const id of message.callbackIds) {
            this.argumentCallbacks.delete(id);
        }
    }
    async onRemoteReadyToReceive(message) {
        if (this.isRemoteReady && !message.requiresResponse) {
            return;
        }
        this.isRemoteReady = true;
        for (const listener of this.remoteReadyListeners) {
            listener();
        }
        // If ready, re-send the RemoteReady message, it may have been sent before
        // the remote first loaded.
        if (this.isLocalReady) {
            this.postMessage({
                kind: MessageType.RemoteReady,
                channelId: this.channelId,
                // We already know that the remote is ready, so
                // another response isn't necessary.
                requiresResponse: false,
            });
        }
    }
    awaitRemoteReady() {
        return new Promise(resolve => {
            if (this.isRemoteReady) {
                resolve();
            }
            else {
                this.remoteReadyListeners.push(() => resolve());
            }
        });
    }
    // Wait for all methods to have received a response.
    // This can be used to check whether it's safe to destroy a remote, or
    // whether doing so will cause a method to never resolve.
    awaitAllMethodsRespondedTo() {
        return new Promise(resolve => {
            if (this.numberUnrespondedToMethods === 0) {
                resolve();
            }
            else {
                this.noWaitingMethodsListeners.push(resolve);
            }
        });
    }
    // Should be called by subclasses when a message is received.
    async onMessage(message) {
        if (this.closed) {
            return;
        }
        if (!(typeof message === 'object')) {
            throw new Error(`Invalid message. Messages passed to onMessage must have type "object". Was type ${typeof message}.`);
        }
        if (Array.isArray(message)) {
            throw new Error('Message must be an object (is an array).');
        }
        // Web transferable objects (note: will not be properly transferred with all messengers).
        if ((0, isTransferableObject_1.default)(message)) {
            throw new Error('Message must be a key-value pair object.');
        }
        if (typeof message.kind !== 'string') {
            throw new Error(`message.kind must be a string, was ${typeof message.kind}`);
        }
        // We just verified that message.kind is a MessageType,
        // assume that all other properties are valid.
        const asInternalMessage = message;
        // If intended for a different set of messengers...
        if (asInternalMessage.channelId !== this.channelId) {
            return;
        }
        if (asInternalMessage.kind === MessageType.InvokeMethod) {
            await this.invokeLocalMethod(asInternalMessage);
        }
        else if (asInternalMessage.kind === MessageType.CloseChannel) {
            void this.onClose();
        }
        else if (asInternalMessage.kind === MessageType.ReturnValueResponse) {
            await this.onRemoteResolve(asInternalMessage);
        }
        else if (asInternalMessage.kind === MessageType.ErrorResponse) {
            await this.onRemoteReject(asInternalMessage);
        }
        else if (asInternalMessage.kind === MessageType.RemoteReady) {
            await this.onRemoteReadyToReceive(asInternalMessage);
        }
        else if (asInternalMessage.kind === MessageType.OnCallbackDropped) {
            await this.onRemoteCallbackDropped(asInternalMessage);
        }
        else {
            // Have TypeScript verify that the above cases are exhaustive
            const exhaustivenessCheck = asInternalMessage;
            throw new Error(`Invalid message type, ${message.kind}. Message: ${exhaustivenessCheck}`);
        }
    }
    // Subclasses should call this method when ready to receive data
    onReadyToReceive() {
        if (this.isLocalReady) {
            if (!this.isRemoteReady) {
                this.postMessage({
                    kind: MessageType.RemoteReady,
                    channelId: this.channelId,
                    requiresResponse: true,
                });
            }
            return;
        }
        if (this.localInterface === null) {
            this.waitingForLocalInterface = true;
            return;
        }
        this.isLocalReady = true;
        this.postMessage({
            kind: MessageType.RemoteReady,
            channelId: this.channelId,
            requiresResponse: !this.isRemoteReady,
        });
    }
    setLocalInterface(localInterface) {
        this.localInterface = localInterface;
        if (this.waitingForLocalInterface) {
            this.waitingForLocalInterface = false;
            this.onReadyToReceive();
        }
    }
    // Should be called if this messenger is in the middle (not on the edge) of a chain
    // For example, if we have the following setup,
    //    React Native <-Messenger(1) | Messenger(2)-> WebView <-Messenger(3) | Messenger(4)-> Worker
    // Messenger(2) and Messenger(3) should call `setIsChainedMessenger(false)`.
    setIsChainedMessenger(isChained) {
        this.preserveThis = !isChained;
    }
    // Disconnects both this and the remote.
    closeConnection() {
        this.closed = true;
        this.postMessage({ channelId: this.channelId, kind: MessageType.CloseChannel });
        this.onClose();
    }
    hasBeenClosed() {
        return this.closed;
    }
    // For testing
    getIdForCallback_(callback) {
        for (const [id, otherCallback] of this.argumentCallbacks) {
            if (otherCallback === callback) {
                return id;
            }
        }
        return undefined;
    }
}
exports.default = RemoteMessenger;
//# sourceMappingURL=RemoteMessenger.js.map