Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for nested method structures #71

Merged
merged 1 commit into from
Jul 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ connection.promise.then((parent) => {

`options.iframe: HTMLIFrameElement` (required) The iframe element to which Penpal should connect. Unless you provide the `childOrigin` option, you will need to have set either the `src` or `srcdoc` property on the iframe prior to calling `connectToChild` so that Penpal can automatically derive the child origin. In addition to regular URLs, [data URIs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) and [file URIs](https://en.wikipedia.org/wiki/File_URI_scheme) are also supported.

`options.methods: Object` (optional) An object containing methods which should be exposed for the child iframe to call. The keys of the object are the method names and the values are the functions. If a function requires asynchronous processing to determine its return value, make the function immediately return a promise and resolve the promise once the value has been determined.
`options.methods: Object` (optional) An object containing methods which should be exposed for the child iframe to call. The keys of the object are the method names and the values are the functions. Nested objects with function values are recursively included. If a function requires asynchronous processing to determine its return value, make the function immediately return a promise and resolve the promise once the value has been determined.

`options.childOrigin: string` (optional) In the vast majority of cases, Penpal can automatically determine the child origin based on the `src` or `srcdoc` property that you have set on the iframe. Unfortunately, browsers are inconsistent in certain cases, particularly when using the `file://` protocol on various devices. If you receive an error saying that the parent received a handshake from an unexpected origin, you may need to manually pass the child origin using this option.

Expand All @@ -134,7 +134,7 @@ The return value of `connectToChild` is a `connection` object with the following

`options.parentOrigin: string | RegExp` (optional) The origin of the parent window which your iframe will be communicating with. If this is not provided, communication will not be restricted to any particular parent origin resulting in any webpage being able to load your webpage into an iframe and communicate with it.

`options.methods: Object` (optional) An object containing methods which should be exposed for the parent window to call. The keys of the object are the method names and the values are the functions. If a function requires asynchronous processing to determine its return value, make the function immediately return a promise and resolve the promise once the value has been determined.
`options.methods: Object` (optional) An object containing methods which should be exposed for the parent window to call. The keys of the object are the method names and the values are the functions. Nested objects with function values are recursively included. If a function requires asynchronous processing to determine its return value, make the function immediately return a promise and resolve the promise once the value has been determined.

`options.timeout: number` (optional) The amount of time, in milliseconds, Penpal should wait for the parent to respond before rejecting the connection promise. There is no timeout by default.

Expand Down
4 changes: 3 additions & 1 deletion src/child/connectToParent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '../types';
import { ErrorCode, MessageType, NativeEventType } from '../enums';
import handleSynAckMessageFactory from './handleSynAckMessageFactory';
import { serializeMethods } from '../methodSerialization';
import startConnectionTimeout from '../startConnectionTimeout';

const areGlobalsAccessible = () => {
Expand Down Expand Up @@ -62,10 +63,11 @@ export default <TCallSender extends object = CallSender>(
const log = createLogger(debug);
const destructor = createDestructor('Child', log);
const { destroy, onDestroy } = destructor;
const serializedMethods = serializeMethods(methods);

const handleSynAckMessage = handleSynAckMessageFactory(
parentOrigin,
methods,
serializedMethods,
destructor,
log
);
Expand Down
17 changes: 13 additions & 4 deletions src/child/handleSynAckMessageFactory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { AckMessage, CallSender, Methods, WindowsInfo } from '../types';
import {
AckMessage,
CallSender,
SerializedMethods,
WindowsInfo,
} from '../types';
import { MessageType } from '../enums';
import connectCallReceiver from '../connectCallReceiver';
import connectCallSender from '../connectCallSender';
Expand All @@ -9,7 +14,7 @@ import { Destructor } from '../createDestructor';
*/
export default (
parentOrigin: string | RegExp,
methods: Methods,
serializedMethods: SerializedMethods,
destructor: Destructor,
log: Function
) => {
Expand Down Expand Up @@ -37,7 +42,7 @@ export default (

const ackMessage: AckMessage = {
penpal: MessageType.Ack,
methodNames: Object.keys(methods),
methodNames: Object.keys(serializedMethods),
};

window.parent.postMessage(ackMessage, originForSending);
Expand All @@ -50,7 +55,11 @@ export default (
originForReceiving: event.origin,
};

const destroyCallReceiver = connectCallReceiver(info, methods, log);
const destroyCallReceiver = connectCallReceiver(
info,
serializedMethods,
log
);
onDestroy(destroyCallReceiver);

const callSender: CallSender = {};
Expand Down
15 changes: 12 additions & 3 deletions src/connectCallReceiver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { serializeError } from './errorSerialization';
import { CallMessage, Methods, ReplyMessage, WindowsInfo } from './types';
import {
CallMessage,
SerializedMethods,
ReplyMessage,
WindowsInfo,
} from './types';
import {
MessageType,
NativeEventType,
Expand All @@ -11,7 +16,11 @@ import {
* Listens for "call" messages coming from the remote, executes the corresponding method, and
* responds with the return value.
*/
export default (info: WindowsInfo, methods: Methods, log: Function) => {
export default (
info: WindowsInfo,
serializedMethods: SerializedMethods,
log: Function
) => {
const {
localName,
local,
Expand Down Expand Up @@ -91,7 +100,7 @@ export default (info: WindowsInfo, methods: Methods, log: Function) => {
};

new Promise((resolve) =>
resolve(methods[methodName].apply(methods, args))
resolve(serializedMethods[methodName].apply(serializedMethods, args))
).then(
createPromiseHandler(Resolution.Fulfilled),
createPromiseHandler(Resolution.Rejected)
Expand Down
18 changes: 13 additions & 5 deletions src/connectCallSender.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import generateId from './generateId';
import { deserializeError } from './errorSerialization';
import { deserializeMethods } from './methodSerialization';
import {
CallMessage,
CallSender,
Expand All @@ -15,15 +16,15 @@ import { ErrorCode, MessageType, NativeEventType, Resolution } from './enums';
* executed, and the method's return value will be returned via a message.
* @param {Object} callSender Sender object that should be augmented with methods.
* @param {Object} info Information about the local and remote windows.
* @param {Array} methodNames Names of methods available to be called on the remote.
* @param {Array} methodKeyPaths Key paths of methods available to be called on the remote.
* @param {Promise} destructionPromise A promise resolved when destroy() is called on the penpal
* connection.
* @returns {Object} The call sender object with methods that may be called.
*/
export default (
callSender: CallSender,
info: WindowsInfo,
methodNames: string[],
methodKeyPaths: string[],
destroyConnection: Function,
log: Function
) => {
Expand Down Expand Up @@ -122,10 +123,17 @@ export default (
};
};

methodNames.reduce((api, methodName) => {
api[methodName] = createMethodProxy(methodName);
// Wrap each method in a proxy which sends it to the corresponding receiver.
const flattenedMethods = methodKeyPaths.reduce<
Record<string, () => Promise<unknown>>
>((api, name) => {
api[name] = createMethodProxy(name);
return api;
}, callSender);
}, {});

// Unpack the structure of the provided methods object onto the CallSender, exposing
// the methods in the same shape they were provided.
Object.assign(callSender, deserializeMethods(flattenedMethods));

return () => {
destroyed = true;
Expand Down
95 changes: 95 additions & 0 deletions src/methodSerialization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { SerializedMethods, Methods } from './types';

const KEY_PATH_DELIMITER = '.';

const keyPathToSegments = (keyPath: string) =>
keyPath ? keyPath.split(KEY_PATH_DELIMITER) : [];
const segmentsToKeyPath = (segments: string[]) =>
segments.join(KEY_PATH_DELIMITER);

const createKeyPath = (key: string, prefix?: string) => {
const segments = keyPathToSegments(prefix || '');
segments.push(key);
return segmentsToKeyPath(segments);
};

/**
* Given a `keyPath`, set it to be `value` on `subject`, creating any intermediate
* objects along the way.
*
* @param {Object} subject The object on which to set value.
* @param {string} keyPath The key path at which to set value.
* @param {Object} value The value to store at the given key path.
* @returns {Object} Updated subject.
*/
export const setAtKeyPath = (
subject: Record<string, any>,
keyPath: string,
value: any
) => {
const segments = keyPathToSegments(keyPath);

segments.reduce((prevSubject, key, idx) => {
if (typeof prevSubject[key] === 'undefined') {
prevSubject[key] = {};
}

if (idx === segments.length - 1) {
prevSubject[key] = value;
}

return prevSubject[key];
}, subject);

return subject;
};

/**
* Given a dictionary of (nested) keys to function, flatten them to a map
* from key path to function.
*
* @param {Object} methods The (potentially nested) object to serialize.
* @param {string} prefix A string with which to prefix entries. Typically not intended to be used by consumers.
* @returns {Object} An map from key path in `methods` to functions.
*/
export const serializeMethods = (
methods: Methods,
prefix?: string
): SerializedMethods => {
const flattenedMethods: SerializedMethods = {};

Object.keys(methods).forEach((key) => {
const value = methods[key];
const keyPath = createKeyPath(key, prefix);

if (typeof value === 'object') {
// Recurse into any nested children.
Object.assign(flattenedMethods, serializeMethods(value, keyPath));
}

if (typeof value === 'function') {
// If we've found a method, expose it.
flattenedMethods[keyPath] = value;
}
});

return flattenedMethods;
};

/**
* Given a map of key paths to functions, unpack the key paths to an object.
*
* @param {Object} flattenedMethods A map of key paths to functions to unpack.
* @returns {Object} A (potentially nested) map of functions.
*/
export const deserializeMethods = (
flattenedMethods: SerializedMethods
): Methods => {
const methods: Methods = {};

for (const keyPath in flattenedMethods) {
setAtKeyPath(methods, keyPath, flattenedMethods[keyPath]);
}

return methods;
};
6 changes: 4 additions & 2 deletions src/parent/connectToChild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import createLogger from '../createLogger';
import getOriginFromSrc from './getOriginFromSrc';
import handleAckMessageFactory from './handleAckMessageFactory';
import handleSynMessageFactory from './handleSynMessageFactory';
import { serializeMethods } from '../methodSerialization';
import monitorIframeRemoval from './monitorIframeRemoval';
import startConnectionTimeout from '../startConnectionTimeout';
import validateIframeHasSrcOrSrcDoc from './validateIframeHasSrcOrSrcDoc';
Expand Down Expand Up @@ -63,14 +64,15 @@ export default <TCallSender extends object = CallSender>(
// must post messages with "*" as targetOrigin when sending messages.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#Using_window.postMessage_in_extensions
const originForSending = childOrigin === 'null' ? '*' : childOrigin;
const serializedMethods = serializeMethods(methods);
const handleSynMessage = handleSynMessageFactory(
log,
methods,
serializedMethods,
childOrigin,
originForSending
);
const handleAckMessage = handleAckMessageFactory(
methods,
serializedMethods,
childOrigin,
originForSending,
destructor,
Expand Down
6 changes: 3 additions & 3 deletions src/parent/handleAckMessageFactory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CallSender, Methods, WindowsInfo } from '../types';
import { CallSender, SerializedMethods, WindowsInfo } from '../types';
import { Destructor } from '../createDestructor';
import connectCallReceiver from '../connectCallReceiver';
import connectCallSender from '../connectCallSender';
Expand All @@ -7,7 +7,7 @@ import connectCallSender from '../connectCallSender';
* Handles an ACK handshake message.
*/
export default (
methods: Methods,
serializedMethods: SerializedMethods,
childOrigin: string,
originForSending: string,
destructor: Destructor,
Expand Down Expand Up @@ -46,7 +46,7 @@ export default (
destroyCallReceiver();
}

destroyCallReceiver = connectCallReceiver(info, methods, log);
destroyCallReceiver = connectCallReceiver(info, serializedMethods, log);
onDestroy(destroyCallReceiver);

// If the child reconnected, we need to remove the methods from the
Expand Down
6 changes: 3 additions & 3 deletions src/parent/handleSynMessageFactory.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Methods, SynAckMessage } from '../types';
import { SerializedMethods, SynAckMessage } from '../types';
import { MessageType } from '../enums';

/**
* Handles a SYN handshake message.
*/
export default (
log: Function,
methods: Methods,
serializedMethods: SerializedMethods,
childOrigin: string,
originForSending: string
) => {
Expand All @@ -22,7 +22,7 @@ export default (

const synAckMessage: SynAckMessage = {
penpal: MessageType.SynAck,
methodNames: Object.keys(methods),
methodNames: Object.keys(serializedMethods),
};

(event.source as Window).postMessage(synAckMessage, originForSending);
Expand Down
37 changes: 22 additions & 15 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,24 @@ export type AckMessage = {
};

/**
* A mapped type to convert non async methods into async methods and exclude any non function properties.
* Extract keys of T whose values are are assignable to U.
*/
export type AsyncMethodReturns<
T,
K extends keyof T = FunctionPropertyNames<T>
> = {
[KK in K]: T[KK] extends (...args: any[]) => PromiseLike<any>
? T[KK]
: T[KK] extends (...args: infer A) => infer R
type ExtractKeys<T, U> = {
[P in keyof T]: T[P] extends U ? P : never;
}[keyof T];

/**
* A mapped type to recursively convert non async methods into async methods and exclude
* any non function properties from T.
*/
export type AsyncMethodReturns<T> = {
[K in ExtractKeys<T, Function | object>]: T[K] extends (
...args: any
) => PromiseLike<any>
? T[K]
: T[K] extends (...args: infer A) => infer R
? (...args: A) => Promise<R>
: T[KK];
: AsyncMethodReturns<T[K]>;
};

/**
Expand Down Expand Up @@ -55,16 +62,16 @@ export type Connection<TCallSender extends object = CallSender> = {
};

/**
* A mapped type to extract only object properties which are functions.
* Methods to expose to the remote window.
*/
export type FunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
export type Methods = {
[index: string]: Methods | Function;
};

/**
* Methods to expose to the remote window.
* A map of key path to function. The flatted counterpart of Methods.
*/
export type Methods = {
export type SerializedMethods = {
[index: string]: Function;
};

Expand Down
Loading