mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-27 10:32:58 +02:00
573 lines
19 KiB
TypeScript
573 lines
19 KiB
TypeScript
import { CallbackIds as CallbackIds, SerializableData, SerializableDataAndCallbacks, TransferableCallback } from './types';
|
|
import mergeCallbacksAndSerializable from './utils/mergeCallbacksAndSerializable';
|
|
import separateCallbacksFromSerializable from './utils/separateCallbacksFromSerializable';
|
|
import separateCallbacksFromSerializableArray from './utils/separateCallbacksFromSerializableArray';
|
|
|
|
enum MessageType {
|
|
RemoteReady = 'RemoteReady',
|
|
InvokeMethod = 'InvokeMethod',
|
|
ErrorResponse = 'ErrorResponse',
|
|
ReturnValueResponse = 'ReturnValueResponse',
|
|
CloseChannel = 'CloseChannel',
|
|
OnCallbackDropped = 'OnCallbackDropped',
|
|
}
|
|
|
|
type RemoteReadyMessage = Readonly<{
|
|
kind: MessageType.RemoteReady;
|
|
requiresResponse: boolean;
|
|
}>;
|
|
|
|
type InvokeMethodMessage = Readonly<{
|
|
kind: MessageType.InvokeMethod;
|
|
|
|
respondWithId: string;
|
|
methodPath: string[];
|
|
arguments: {
|
|
serializable: SerializableData[];
|
|
|
|
// Stores identifiers for callbacks within the normal `arguments`.
|
|
// For example,
|
|
// [{ foo: 'some-id-here' }, null, 'some-id-here-2']
|
|
// means that the first argument has a property named "foo" that is a function
|
|
// and the third argument is also a function.
|
|
callbacks: CallbackIds[];
|
|
};
|
|
}>;
|
|
|
|
type ReturnValueResponse = Readonly<{
|
|
kind: MessageType.ReturnValueResponse;
|
|
|
|
responseId: string;
|
|
returnValue: {
|
|
serializable: SerializableData;
|
|
callbacks: CallbackIds;
|
|
};
|
|
}>;
|
|
|
|
type ErrorResponse = Readonly<{
|
|
kind: MessageType.ErrorResponse;
|
|
|
|
responseId: string;
|
|
errorMessage: string;
|
|
}>;
|
|
|
|
// Disconnect
|
|
type CloseChannelMessage = Readonly<{
|
|
kind: MessageType.CloseChannel;
|
|
}>;
|
|
|
|
type CallbackDroppedMessage = Readonly<{
|
|
kind: MessageType.OnCallbackDropped;
|
|
callbackIds: string[];
|
|
}>;
|
|
|
|
type BaseMessage = Readonly<{
|
|
channelId: string;
|
|
}>;
|
|
|
|
type InternalMessage = (RemoteReadyMessage|CloseChannelMessage|InvokeMethodMessage|ErrorResponse|ReturnValueResponse|CallbackDroppedMessage) & BaseMessage;
|
|
|
|
// Listeners for a remote method to resolve or reject.
|
|
type OnMethodResolveListener = (returnValue: SerializableDataAndCallbacks)=> void;
|
|
type OnMethodRejectListener = (errorMessage: string)=> void;
|
|
type OnRemoteReadyListener = ()=> void;
|
|
|
|
type OnAllMethodsRespondedToListener = ()=> void;
|
|
|
|
// TODO: Remove after upgrading nodejs/browser types sufficiently
|
|
// (FinalizationRegistry is supported in modern browsers).
|
|
declare class FinalizationRegistry {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
public constructor(onDrop: any);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
public register(v: any, id: string): void;
|
|
}
|
|
|
|
// A thin wrapper around postMessage. A script within `targetWindow` should
|
|
// also construct a RemoteMessenger (with IncomingMessageType and
|
|
// OutgoingMessageType reversed).
|
|
export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
|
|
private resolveMethodCallbacks: Record<string, OnMethodResolveListener> = Object.create(null);
|
|
private rejectMethodCallbacks: Record<string, OnMethodRejectListener> = Object.create(null);
|
|
private argumentCallbacks: Map<string, TransferableCallback> = new Map();
|
|
private callbackTracker: FinalizationRegistry|undefined = undefined;
|
|
|
|
private numberUnrespondedToMethods = 0;
|
|
private noWaitingMethodsListeners: OnAllMethodsRespondedToListener[] = [];
|
|
|
|
private remoteReadyListeners: OnRemoteReadyListener[] = [];
|
|
private isRemoteReady = false;
|
|
private isLocalReady = false;
|
|
private nextResponseId = 0;
|
|
private closed = false;
|
|
|
|
// If true, we'll be ready to receive data after .setLocalInterface is next called.
|
|
private waitingForLocalInterface = false;
|
|
|
|
public readonly remoteApi: RemoteInterface;
|
|
|
|
// 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.
|
|
private preserveThis = true;
|
|
|
|
// 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.
|
|
public constructor(private channelId: string, private localInterface: LocalInterface|null) {
|
|
const makeApiFor = (methodPath: string[]) => {
|
|
// 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: string): any => {
|
|
if (property === '___is_joplin_wrapper___') {
|
|
return true;
|
|
} else {
|
|
return makeApiFor([...methodPath, property]);
|
|
}
|
|
},
|
|
apply: (_target, _thisArg, argumentsList: SerializableDataAndCallbacks[]) => {
|
|
return this.invokeRemoteMethod(methodPath, argumentsList);
|
|
},
|
|
});
|
|
};
|
|
this.remoteApi = makeApiFor([]) as RemoteInterface;
|
|
|
|
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: string) => {
|
|
this.dropRemoteCallback_(callbackId);
|
|
});
|
|
}
|
|
}
|
|
|
|
private createResponseId(methodPath: string[]) {
|
|
return `${methodPath.join(',')}-${this.nextResponseId++}`;
|
|
}
|
|
|
|
private registerCallbacks(idToCallbacks: Record<string, TransferableCallback>) {
|
|
for (const id in idToCallbacks) {
|
|
this.argumentCallbacks.set(id, idToCallbacks[id]);
|
|
}
|
|
}
|
|
|
|
private lastCallbackDropTime_ = 0;
|
|
private bufferedDroppedCallbackIds_: string[] = [];
|
|
// protected: For testing
|
|
protected dropRemoteCallback_(callbackId: string) {
|
|
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();
|
|
}
|
|
|
|
private async invokeRemoteMethod(methodPath: string[], args: SerializableDataAndCallbacks[]) {
|
|
// Function arguments can't be transferred using standard .postMessage calls.
|
|
// As such, we assign them IDs and transfer the IDs instead:
|
|
const separatedArgs = separateCallbacksFromSerializableArray(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<SerializableDataAndCallbacks>((resolve, reject) => {
|
|
const responseId = this.createResponseId(methodPath);
|
|
|
|
this.resolveMethodCallbacks[responseId] = returnValue => {
|
|
resolve(returnValue);
|
|
};
|
|
this.rejectMethodCallbacks[responseId] = (errorMessage: string) => {
|
|
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
|
|
private canRemoteAccessProperty(parentObject: any, methodName: string) {
|
|
// 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;
|
|
}
|
|
|
|
private onInvokeCallback = (callbackId: string, callbackArgs: SerializableDataAndCallbacks[]) => {
|
|
return this.invokeRemoteMethod(['__callbacks', callbackId], callbackArgs);
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
private trackCallbackFinalization = (callbackId: string, callback: any) => {
|
|
this.callbackTracker?.register(callback, callbackId);
|
|
};
|
|
|
|
// Calls a local method and sends the result to the remote connection.
|
|
private async invokeLocalMethod(message: InvokeMethodMessage) {
|
|
try {
|
|
const methodFromPath = (path: string[]) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
const parentObjectStack: any[] = [];
|
|
|
|
// 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: any;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
let currentObject: any = 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 = mergeCallbacksAndSerializable(
|
|
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 = separateCallbacksFromSerializable(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?.methodPath}): `, error, error.stack, JSON.stringify(message));
|
|
|
|
this.postMessage({
|
|
kind: MessageType.ErrorResponse,
|
|
responseId: message.respondWithId,
|
|
errorMessage: `${error} (Calling ${message?.methodPath?.join?.('.')})`,
|
|
channelId: this.channelId,
|
|
});
|
|
}
|
|
}
|
|
|
|
private onMethodRespondedTo(responseId: string) {
|
|
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');
|
|
}
|
|
}
|
|
|
|
private async onRemoteResolve(message: ReturnValueResponse) {
|
|
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 = mergeCallbacksAndSerializable(
|
|
message.returnValue.serializable,
|
|
message.returnValue.callbacks,
|
|
this.onInvokeCallback,
|
|
this.trackCallbackFinalization,
|
|
);
|
|
|
|
this.resolveMethodCallbacks[message.responseId](returnValue);
|
|
this.onMethodRespondedTo(message.responseId);
|
|
}
|
|
|
|
private async onRemoteReject(message: ErrorResponse) {
|
|
this.rejectMethodCallbacks[message.responseId](message.errorMessage);
|
|
this.onMethodRespondedTo(message.responseId);
|
|
}
|
|
|
|
private async onRemoteCallbackDropped(message: CallbackDroppedMessage) {
|
|
for (const id of message.callbackIds) {
|
|
this.argumentCallbacks.delete(id);
|
|
}
|
|
}
|
|
|
|
private async onRemoteReadyToReceive(message: RemoteReadyMessage) {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
public awaitRemoteReady() {
|
|
return new Promise<void>(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.
|
|
public awaitAllMethodsRespondedTo() {
|
|
return new Promise<void>(resolve => {
|
|
if (this.numberUnrespondedToMethods === 0) {
|
|
resolve();
|
|
} else {
|
|
this.noWaitingMethodsListeners.push(resolve);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Should be called by subclasses when a message is received.
|
|
protected async onMessage(message: SerializableData): Promise<void> {
|
|
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).');
|
|
}
|
|
|
|
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 as InternalMessage;
|
|
|
|
// 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: never = asInternalMessage;
|
|
throw new Error(`Invalid message type, ${message.kind}. Message: ${exhaustivenessCheck}`);
|
|
}
|
|
}
|
|
|
|
// Subclasses should call this method when ready to receive data
|
|
protected 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,
|
|
});
|
|
}
|
|
|
|
public setLocalInterface(localInterface: 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)`.
|
|
public setIsChainedMessenger(isChained: boolean) {
|
|
this.preserveThis = !isChained;
|
|
}
|
|
|
|
// Disconnects both this and the remote.
|
|
public closeConnection() {
|
|
this.closed = true;
|
|
this.postMessage({ channelId: this.channelId, kind: MessageType.CloseChannel });
|
|
this.onClose();
|
|
}
|
|
|
|
public hasBeenClosed() {
|
|
return this.closed;
|
|
}
|
|
|
|
protected abstract postMessage(message: InternalMessage): void;
|
|
protected abstract onClose(): void;
|
|
|
|
|
|
// For testing
|
|
public getIdForCallback_(callback: TransferableCallback) {
|
|
for (const [id, otherCallback] of this.argumentCallbacks) {
|
|
if (otherCallback === callback) {
|
|
return id;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|