diff --git a/apps/web/components/ComponentMonitor.tsx b/apps/web/components/ComponentMonitor.tsx index 7eddcdae..bd66a2dd 100644 --- a/apps/web/components/ComponentMonitor.tsx +++ b/apps/web/components/ComponentMonitor.tsx @@ -181,7 +181,7 @@ export function ComponentMonitor({ badgeClass: 'bg-success', name: 'return', componentId: parseComponentId( - isFromComponent ? message.componentId : message.targetId + isFromComponent ? message.containerId : message.targetId )!, summary: `[${requestId.split('-')[0]}] returned ${result} ${ !isFromComponent ? 'to' : '' @@ -189,14 +189,13 @@ export function ComponentMonitor({ }; } case 'component.update': { - const { __componentcallbacks, ...simpleProps } = message.props || {}; return { message, isFromComponent, badgeClass: 'bg-warning', name: 'update', componentId: parseComponentId(toComponent!)!, - summary: `updated props ${JSON.stringify(simpleProps)} on`, + summary: `updated props ${JSON.stringify(message.props || {})} on`, }; } case 'component.domCallback': { diff --git a/packages/application/src/container.ts b/packages/application/src/container.ts index 3a5292aa..a8d2ce79 100644 --- a/packages/application/src/container.ts +++ b/packages/application/src/container.ts @@ -49,50 +49,50 @@ export function deserializeProps({ } delete props.__bweMeta; - if (!props.__domcallbacks) { - return props; - } - Object.entries(props.__domcallbacks).forEach( - ([propKey, callback]: [string, any]) => { - props[propKey.split('::')[0]] = (...args: any[]) => { - let serializedArgs: any = args; - const event = args[0] || {}; + return Object.fromEntries( + Object.entries(props).map(([k, v]) => { + const callbackMeta = v as { callbackIdentifier: string } | any; + if (!callbackMeta?.callbackIdentifier) { + return [k, v]; + } + + const { callbackIdentifier } = callbackMeta; + return [ + k, + (...args: any[]) => { + let serializedArgs: any = args; + const event = args[0] || {}; // TODO make this opt-in/out? event.preventDefault?.(); - const { target } = event; - // is this a DOM event? - if (target && typeof target === 'object') { + const { target } = event;// is this a DOM event? + if (target && typeof target === 'object') { const { checked, name, type, value } = target; - serializedArgs = { - event: { - target: { - checked, - name, - type, - value, + serializedArgs = { + event: { + target: { + checked, + name, + type, + value, + }, }, - }, - }; - } + }; + } - sendMessage({ - componentId: id, - message: { - args: serializedArgs, - method: callback.__componentMethod, - type: 'component.domCallback', - }, - onMessageSent, - }); - }; - } + sendMessage({ + componentId: id, + message: { + args: serializedArgs, + method: callbackIdentifier, + type: 'component.domCallback', + }, + onMessageSent, + }); + }, + ]; + }) ); - - delete props.__domcallbacks; - delete props.__componentcallbacks; - - return props; } diff --git a/packages/application/src/handlers.ts b/packages/application/src/handlers.ts index 43209662..41568173 100644 --- a/packages/application/src/handlers.ts +++ b/packages/application/src/handlers.ts @@ -39,11 +39,11 @@ export function onCallbackResponse({ a component has executed a callback invoked from another component return the value of the callback execution to the calling component */ - const { requestId, result, targetId, componentId } = data; + const { requestId, result, targetId, containerId } = data; sendMessage({ componentId: targetId, message: { - componentId, + containerId, result, requestId, targetId, diff --git a/packages/common/src/types/messaging.ts b/packages/common/src/types/messaging.ts index e2cb4b59..967644ba 100644 --- a/packages/common/src/types/messaging.ts +++ b/packages/common/src/types/messaging.ts @@ -34,7 +34,7 @@ export interface ComponentCallbackInvocation extends PostMessageParams { } export interface ComponentCallbackResponse extends PostMessageParams { - componentId: string; + containerId: string; requestId: string; result: string; // stringified JSON in the form of { result: any, error: string } targetId: string; diff --git a/packages/common/src/types/render.ts b/packages/common/src/types/render.ts index 12b66b30..57634404 100644 --- a/packages/common/src/types/render.ts +++ b/packages/common/src/types/render.ts @@ -10,8 +10,6 @@ export interface KeyValuePair { export interface Props extends KeyValuePair { __bweMeta?: WebEngineMeta; - __domcallbacks?: { [key: string]: any }; - __componentcallbacks?: { [key: string]: any }; children?: any[]; className?: string; id?: string; diff --git a/packages/common/src/types/serialization.ts b/packages/common/src/types/serialization.ts index 9441e321..0a884017 100644 --- a/packages/common/src/types/serialization.ts +++ b/packages/common/src/types/serialization.ts @@ -1,5 +1,4 @@ import type { Props } from './render'; -import { KeyValuePair } from './render'; import type { ComponentTrust } from './trust'; export interface ComponentChildMetadata { @@ -10,7 +9,7 @@ export interface ComponentChildMetadata { } export type SerializedArgs = Array< - string | number | object | any[] | { __componentMethod: string } + string | number | object | any[] | { callbackIdentifier: string } >; export interface SerializedNode { @@ -18,14 +17,3 @@ export interface SerializedNode { type: string; props: Props; } - -export interface SerializedComponentCallback { - __componentMethod: string; - parentId: string; -} - -export interface SerializedProps extends KeyValuePair { - __componentcallbacks?: { - [key: string]: SerializedComponentCallback; - }; -} diff --git a/packages/container/src/callbacks.ts b/packages/container/src/callbacks.ts index 9e4b29e3..c0a3e02f 100644 --- a/packages/container/src/callbacks.ts +++ b/packages/container/src/callbacks.ts @@ -40,7 +40,7 @@ export function invokeComponentCallback({ args, buildRequest, callbacks, - componentId, + containerId, invokeCallback, method, postCallbackInvocationMessage, @@ -49,7 +49,7 @@ export function invokeComponentCallback({ }: InvokeComponentCallbackParams): any { // unknown method if (!callbacks[method]) { - console.error(`No method ${method} on component ${componentId}`); + console.error(`No method ${method} on container ${containerId}`); return null; } @@ -57,11 +57,11 @@ export function invokeComponentCallback({ // these must be replaced with wrappers invoking Component methods if ( typeof args?.some === 'function' && - args.some((arg: any) => arg.__componentMethod) + args.some((arg: any) => arg?.callbackIdentifier) ) { args = args.map((arg: any) => { - const { __componentMethod: componentMethod } = arg; - if (!componentMethod) { + const { callbackIdentifier } = arg; + if (!callbackIdentifier) { return arg; } @@ -72,12 +72,11 @@ export function invokeComponentCallback({ postCallbackInvocationMessage({ args: childArgs, callbacks, - componentId, - method: componentMethod, + containerId, + method: callbackIdentifier, requestId, - // TODO must specify a real value here serializeArgs, - targetId: componentMethod.split('::').slice(1).join('::'), + targetId: callbackIdentifier.split('::').slice(1).join('::'), }); }; }); diff --git a/packages/container/src/container.ts b/packages/container/src/container.ts index af990042..2f858ffd 100644 --- a/packages/container/src/container.ts +++ b/packages/container/src/container.ts @@ -33,7 +33,7 @@ export function initContainer({ postComponentRenderMessage, } = composeMessagingMethods(); - const { deserializeProps, serializeArgs, serializeNode } = + const { deserializeArgs, deserializeProps, serializeArgs, serializeNode } = composeSerializationMethods({ buildRequest, callbacks, @@ -67,11 +67,11 @@ export function initContainer({ const processEvent = buildEventHandler({ buildRequest, callbacks, - componentId, + containerId: componentId, + deserializeArgs, deserializeProps, invokeCallback, invokeComponentCallback, - parentContainerId, postCallbackInvocationMessage, postCallbackResponseMessage, requests, @@ -97,7 +97,7 @@ export function initContainer({ const props = buildSafeProxy({ componentId, props: deserializeProps({ - componentId, + containerId: componentId, props: componentPropsJson, }), }); diff --git a/packages/container/src/events.ts b/packages/container/src/events.ts index 5f00f13a..23f4c8c2 100644 --- a/packages/container/src/events.ts +++ b/packages/container/src/events.ts @@ -6,11 +6,10 @@ import type { ProcessEventParams } from './types'; * Return an event handler function to be registered under `window.addEventHandler('message', fn(event))` * @param buildRequest Function to build an inter-Component asynchronous callback request * @param callbacks The set of callbacks defined on the target Component - * @param componentId ID of the target Component on which the + * @param containerId ID of the container handling messages * @param deserializeProps Function to deserialize props passed on the event * @param invokeCallback Function to execute the specified function in the current context * @param invokeComponentCallback Function to execute the specified function, either in the current context or another Component's - * @param parentContainerId ID of the parent container * @param postCallbackInvocationMessage Request invocation on external Component via window.postMessage * @param postCallbackResponseMessage Send callback execution result to calling Component via window.postMessage * @param requests The set of inter-Component callback requests being tracked by the Component @@ -21,11 +20,11 @@ import type { ProcessEventParams } from './types'; export function buildEventHandler({ buildRequest, callbacks, - componentId, + containerId, + deserializeArgs, deserializeProps, invokeCallback, invokeComponentCallback, - parentContainerId, postCallbackInvocationMessage, postCallbackResponseMessage, requests, @@ -44,22 +43,17 @@ export function buildEventHandler({ args: SerializedArgs; method: string; }) { - if (!parentContainerId) { - console.error(`no parent container for ${componentId}`); - return; - } - + const deserializedArgs = deserializeArgs({ args, containerId }); return invokeComponentCallback({ - args, + args: deserializedArgs, buildRequest, callbacks, - componentId, + containerId, invokeCallback, method, postCallbackInvocationMessage, requests, serializeArgs, - targetId: parentContainerId, }); } @@ -108,7 +102,7 @@ export function buildEventHandler({ result = applyRecursivelyToComponents(result, (n: any) => serializeNode({ node: n, - parentId: method, + parentId: method.split('::')[0], childComponents: [], }) ); @@ -117,7 +111,7 @@ export function buildEventHandler({ if (requestId) { postCallbackResponseMessage({ error, - componentId, + containerId, requestId, result: value, targetId: originator, @@ -188,7 +182,7 @@ export function buildEventHandler({ case 'component.update': { updateProps( deserializeProps({ - componentId, + containerId, props: event.data.props, }) ); diff --git a/packages/container/src/messaging.ts b/packages/container/src/messaging.ts index 192608c8..c4b5a4fd 100644 --- a/packages/container/src/messaging.ts +++ b/packages/container/src/messaging.ts @@ -35,16 +35,16 @@ export function composeMessagingMethods() { function postCallbackInvocationMessage({ args, callbacks, - componentId, + containerId, method, requestId, serializeArgs, targetId, }: PostMessageComponentCallbackInvocationParams): void { postMessage({ - args: serializeArgs({ args, callbacks, componentId }), + args: serializeArgs({ args, callbacks, containerId }), method, - originator: componentId, + originator: containerId, requestId, targetId, type: 'component.callbackInvocation', @@ -53,7 +53,7 @@ export function composeMessagingMethods() { function postCallbackResponseMessage({ error, - componentId, + containerId, requestId, result, targetId, @@ -63,7 +63,7 @@ export function composeMessagingMethods() { postMessage({ requestId, - componentId, + containerId, result: JSON.stringify({ value: result, error: serializedError, diff --git a/packages/container/src/serialize.ts b/packages/container/src/serialize.ts index 24a928ed..440ee4bc 100644 --- a/packages/container/src/serialize.ts +++ b/packages/container/src/serialize.ts @@ -5,6 +5,7 @@ import type { SerializePropsCallback, SerializeArgsCallback, SerializeNodeCallback, + DeserializeArgsCallback, } from './types'; import { ComposeSerializationMethodsCallback } from './types'; @@ -19,12 +20,30 @@ interface SerializeChildComponentParams { props: Props; } +interface SerializedPropsCallback { + containerId: string; + callbackIdentifier: string; +} + +interface DeepTransformParams { + value: any; + onString?: (s: string) => string; + onFunction?: (f: Function, path: string) => any; + onSerializedCallback?: (cb: SerializedPropsCallback) => Function; +} + +interface BuildContainerMethodIdentifierParams { + callback: Function; + callbackName: string; + componentId?: string; + containerId: string; +} + /** * Compose the set of serialization methods for the given container context * @param buildRequest Method for building callback requests * @param builtinComponents Set of builtin BOS Web Engine Components * @param callbacks Component container's callbacks - * @param parentContainerId ID of the parent container * @param postCallbackInvocationMessage Request invocation on external Component via window.postMessage * @param requests Set of current callback requests */ @@ -33,19 +52,82 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = buildRequest, callbacks, isComponent, - parentContainerId, postCallbackInvocationMessage, requests, }) => { + const isSerializedCallback = (o: any) => + !!o && + typeof o === 'object' && + Object.keys(o).length === 2 && + 'callbackIdentifier' in o && + 'containerId' in o; + + const deepTransform = ({ + value, + onString, + onFunction, + onSerializedCallback, + }: DeepTransformParams) => { + const transform = (v: any, path: string): any => { + if (!v) { + return v; + } + + if ( + isSerializedCallback(v) && + typeof onSerializedCallback === 'function' + ) { + return onSerializedCallback(v); + } + + const isCollection = Array.isArray(v); // TODO handle other collections + if (isCollection) { + return v.map((i: any, idx: number) => + transform(i, `${path}[${idx}]`) + ); + } + + if (typeof v === 'object') { + return Object.fromEntries( + Object.entries(v).map(([k, w]) => [k, transform(w, `${path}.${k}`)]) + ); + } + + if (typeof v === 'string' && typeof onString === 'function') { + return onString(v); + } + + if (typeof v === 'function' && typeof onFunction === 'function') { + return onFunction(v, path); + } + + return v; + }; + + return transform(value, ''); + }; + + const buildContainerMethodIdentifier = ({ + callback, + callbackName, + componentId, + containerId, + }: BuildContainerMethodIdentifierParams) => + [ + containerId, + callback.toString().replace(/\s+/g, ''), + callbackName, + componentId, + ].join('::'); + /** * Serialize props of a child Component to be rendered in the outer application - * @param componentId The target Component ID - * @param parentId Component's parent container + * @param containerId Component's parent container * @param props The props for this container's Component */ const serializeProps: SerializePropsCallback = ({ componentId, - parentId, + containerId, props, }) => { return Object.entries(props).reduce( @@ -56,53 +138,46 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = typeof value === 'object' && '__' in value && '__k' in value; - const isFunction = typeof value === 'function'; const isProxy = value?.__bweMeta?.isProxy || false; - if (!isFunction) { + const serializeCallback = ( + callbackName: string, + callback: Function + ) => { + const fnKey = buildContainerMethodIdentifier({ + callback, + callbackName, + componentId, + containerId, + }); + + callbacks[fnKey] = callback; + + return { + callbackIdentifier: fnKey, + containerId, + }; + }; + + if (typeof value === 'function') { + newProps[key] = serializeCallback(key, value); + } else { let serializedValue = value; if (isComponent) { - serializedValue = serializeNode({ + newProps[key] = serializeNode({ childComponents: [], node: value, - parentId, + parentId: containerId, }); } else if (isProxy) { - serializedValue = { ...serializedValue }; - } - - newProps[key] = serializedValue; - return newProps; - } - - // [componentId] only applies to props on components, use method - // body to distinguish between non-component callbacks - const fnKey = [ - key, - componentId || value.toString().replace(/\\n/g, ''), - parentId, - ].join('::'); - - // @ts-ignore-error - callbacks[fnKey] = value; - - if (componentId) { - if (!newProps.__componentcallbacks) { - newProps.__componentcallbacks = {}; - } - - newProps.__componentcallbacks[key] = { - __componentMethod: fnKey, - parentId, - }; - } else { - if (!newProps.__domcallbacks) { - newProps.__domcallbacks = {}; + newProps[key] = { ...serializedValue }; + } else { + newProps[key] = deepTransform({ + value: serializedValue, + onFunction: (fn: Function, path: string) => + serializeCallback(`${key}${path}`, fn), + }); } - - newProps.__domcallbacks[key] = { - __componentMethod: fnKey, - }; } return newProps; @@ -111,10 +186,25 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = ); }; + const deserializeArgs: DeserializeArgsCallback = ({ + args, + containerId, + }) => { + return deepTransform({ + value: args, + onSerializedCallback: (cb) => { + return deserializePropsCallback({ + containerId, + callbackIdentifier: cb.callbackIdentifier, + }); + }, + }); + }; + const serializeArgs: SerializeArgsCallback = ({ args, callbacks, - componentId, + containerId, }) => { return (args || []).map((arg) => { if (!arg) { @@ -122,7 +212,7 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = } if (Array.isArray(arg)) { - return serializeArgs({ args: arg, callbacks, componentId }); + return serializeArgs({ args: arg, callbacks, containerId }); } if (typeof arg === 'object') { @@ -131,7 +221,7 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = serializeArgs({ args: Object.values(arg), callbacks, - componentId, + containerId, }).map((value, i) => [argKeys[i], value]) ); } @@ -140,62 +230,61 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = return arg; } - const callbackBody = arg.toString().replace(/\\n/g, ''); - const fnKey = callbackBody + '::' + componentId; + const fnKey = buildContainerMethodIdentifier({ + callback: arg, + callbackName: arg?.name, // FIXME + containerId, + }); + callbacks[fnKey] = arg; return { - __componentMethod: fnKey, + callbackIdentifier: fnKey, + containerId, }; }); }; + const deserializePropsCallback = ({ + containerId, + callbackIdentifier, + }: SerializedPropsCallback) => { + return (...args: any) => { + const requestId = window.crypto.randomUUID(); + requests[requestId] = buildRequest(); + + // any function arguments are closures in this child component scope + // and must be cached in the component iframe + postCallbackInvocationMessage({ + args, + callbacks, + containerId, + method: callbackIdentifier, + requestId, + serializeArgs, + targetId: callbackIdentifier.split('::')[0], + }); + + return requests[requestId].promise; + }; + }; + const deserializeProps: DeserializePropsCallback = ({ - componentId, + containerId, props, }) => { - const { __componentcallbacks } = props; - const componentProps = { ...props }; - delete componentProps.__componentcallbacks; - - return { - ...componentProps, - ...Object.entries(__componentcallbacks || {}).reduce( - (componentCallbacks, [methodName, { __componentMethod }]) => { - if (props[methodName]) { - throw new Error( - `'duplicate props key ${methodName} on ${componentId}'` - ); - } - - componentCallbacks[methodName] = (...args: any) => { - if (!parentContainerId) { - console.error('Root Component cannot invoke method on parent'); - return; - } - - const requestId = window.crypto.randomUUID(); - requests[requestId] = buildRequest(); - - // any function arguments are closures in this child component scope - // and must be cached in the component iframe - postCallbackInvocationMessage({ - args, - callbacks, - componentId, - method: __componentMethod, // the key on the props object passed to this Component - requestId, - serializeArgs, - targetId: parentContainerId, - }); - - return requests[requestId].promise; - }; + if (!props || Array.isArray(props) || typeof props !== 'object') { + return props; + } - return componentCallbacks; - }, - {} as { [key: string]: any } - ), - }; + return deepTransform({ + value: props, + onSerializedCallback: (cb) => { + return deserializePropsCallback({ + containerId, + callbackIdentifier: cb.callbackIdentifier, + }); + }, + }); }; function buildComponentId({ @@ -231,9 +320,9 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = trust, props: componentProps ? serializeProps({ - props: componentProps, - parentId, componentId, + containerId: parentId, + props: componentProps, }) : {}, source: src, @@ -253,7 +342,7 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = props: { id: 'dom-' + componentId, __bweMeta: { - componentId: componentId, + componentId, }, className: 'container-child', }, @@ -316,8 +405,8 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = type: serializedElementType, props: { ...serializeProps({ + containerId: parentId, props, - parentId, }), children: unifiedChildren.flat().map((c) => c?.props @@ -334,6 +423,7 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback = }; return { + deserializeArgs, deserializeProps, serializeArgs, serializeNode, diff --git a/packages/container/src/types.ts b/packages/container/src/types.ts index 588006e0..d8c14674 100644 --- a/packages/container/src/types.ts +++ b/packages/container/src/types.ts @@ -4,7 +4,6 @@ import type { Props, SerializedArgs, SerializedNode, - SerializedProps, } from '@bos-web-engine/common'; import { FunctionComponent, VNode } from 'preact'; @@ -19,10 +18,16 @@ export interface CallbackRequest { export type RequestMap = { [key: string]: CallbackRequest }; export type CallbackMap = { [key: string]: Function }; +export type DeserializeArgsCallback = (params: DeserializeArgsParams) => any; +export interface DeserializeArgsParams { + args: any; + containerId: string; +} + export type DeserializePropsCallback = (params: DeserializePropsParams) => any; export interface DeserializePropsParams { - componentId: string; - props: SerializedProps; + containerId: string; + props: Props; } export type EventArgs = { event: any }; @@ -36,13 +41,12 @@ export interface InvokeComponentCallbackParams { args: SerializedArgs; buildRequest: BuildRequestCallback; callbacks: CallbackMap; - componentId: string; + containerId: string; invokeCallback: (args: InvokeCallbackParams) => any; method: string; postCallbackInvocationMessage: PostMessageComponentInvocationCallback; requests: { [key: string]: CallbackRequest }; serializeArgs: SerializeArgsCallback; - targetId: string; } export type PostMessageComponentInvocationCallback = ( @@ -52,18 +56,18 @@ export type PostMessageComponentInvocationCallback = ( export interface PostMessageComponentCallbackInvocationParams { args: any[]; callbacks: CallbackMap; + containerId: string; method: string; requestId: string; serializeArgs: SerializeArgsCallback; targetId: string; - componentId: string; } export type PostMessageComponentResponseCallback = ( message: PostMessageComponentCallbackResponseParams ) => void; export interface PostMessageComponentCallbackResponseParams { - componentId: string; + containerId: string; error: Error | null; requestId: string; result: any; @@ -108,6 +112,7 @@ export interface ComposeSerializationMethodsParams { export type ComposeSerializationMethodsCallback = ( params: ComposeSerializationMethodsParams ) => { + deserializeArgs: DeserializeArgsCallback; deserializeProps: DeserializePropsCallback; serializeArgs: SerializeArgsCallback; serializeNode: SerializeNodeCallback; @@ -124,11 +129,11 @@ export type UpdateContainerPropsCallback = (props: Props) => void; export interface ProcessEventParams { buildRequest: BuildRequestCallback; callbacks: CallbackMap; - componentId: string; + containerId: string; + deserializeArgs: DeserializeArgsCallback; deserializeProps: DeserializePropsCallback; invokeCallback: (args: InvokeCallbackParams) => any; invokeComponentCallback: (args: InvokeComponentCallbackParams) => any; - parentContainerId: string | null; postCallbackInvocationMessage: PostMessageComponentInvocationCallback; postCallbackResponseMessage: PostMessageComponentResponseCallback; requests: RequestMap; @@ -172,7 +177,7 @@ export type SerializeArgsCallback = ( export interface SerializeArgsParams { args: any[]; callbacks: CallbackMap; - componentId: string; + containerId: string; } export interface PreactElement { @@ -203,7 +208,7 @@ export type SerializeNodeCallback = ( export interface SerializePropsParams { componentId?: string; - parentId: string; + containerId: string; props: any; }