/* Copyright 2022 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import type { Emits, GuestConnection, GuestConnectionEvents, HostMethodAddress, GuestApis, CrossRealmObject, Unsubscriber, VirtualApi, UIHostMethods, GuestMetadata, } from "@adobe/uix-core"; import { Emitter, connectIframe } from "@adobe/uix-core"; import { normalizeIframe } from "./dom-utils"; import { compareVersions } from "./utils/comparePackagesVersions"; /** * A specifier for methods to be expected on a remote interface. * * @remarks * A CapabilitySpec is a description of an interface, like a very simplified * type definition. It specifies an object structure and the paths in that * structure that must be functions. (It doesn't specify anything about the * signatures or return values of those functions.) * * Use CapabilitySpec objects as queries, or filters, to get a subset of * installed extensions which have registered methods which match the spec. * * @example * As an extensible app developer, you are making an extension point for spell * check. Your code expects extensions to register an API `spellCheck` with * methods called `spellCheck.correct(text)` and `spellCheck.suggest(text)`. * * ```javascript * async function correctText(text) { * const spellCheckers = host.getLoadedGuests({ * spellCheck: [ * 'correct', * 'suggest' * ] * }); * let correcting = text; * for (const checker of spellCheckers) { * correcting = await checker.apis.spellCheck.correct(correcting); * } * return Promise.all(checkers.map(checker => * checker.apis.spellCheck.suggest(correcting) * )); * } * ``` * * @public */ export type CapabilitySpec = { [Name in keyof T]+?: (keyof T[Name])[]; }; /** * Interface for decoupling of guest Penpal object * @internal */ interface GuestProxyWrapper { // #region Properties (1) /** * Methods from guest */ apis: VirtualApi; // #endregion Properties (1) // #region Public Methods (1) /** * Emit an event in the guest frame */ emit(type: string, detail: unknown): Promise; metadata: GuestMetadata; // #endregion Public Methods (1) } /** @public */ export type PortOptions = { /** * Time in milliseconds to wait for the guest to connect before throwing. */ timeout?: number; /** * Set true to log copiously in the console. */ debug?: boolean; }; const defaultOptions = { timeout: 20000, debug: false, }; const WRAPPER_MARKER = "$_UISDK_FN_WRAPPER"; /** * Marker interface for detection of wrapped functions */ type WrappedFunction = { (...args: unknown[]): unknown; [WRAPPER_MARKER]: boolean; }; /** * Function typeguard * * @param v * @returns boolean */ const isFunction = (v: unknown): v is CallableFunction => { return typeof v === "function"; }; /** * Typeguard for wrapped functions * * @param v * @returns */ const isWrapperFunction = (v: unknown): v is WrappedFunction => { return ( typeof v === "function" && (v as WrappedFunction)[WRAPPER_MARKER] === true ); }; /** * A Port is the Host-maintained object representing an extension running as a * guest. It exposes methods registered by the Guest, and can provide Host * methods back to the guest. * * @remarks * When the Host object loads extensions via {@link Host.load}, it creates a * Port object for each extension. When retrieving and filtering extensions * via {@link Host.(getLoadedGuests:2)}, a list of Port objects is returned. From * the point of view of the extensible app using the Host object, extensions * are always Port objects, which expose the methods registered by the * extension at the {@link Port.apis} property. * * @privateRemarks * We've gone through several possible names for this object. GuestProxy, * GuestInterface, GuestConnection, etc. "Port" is not ideal, but it conflicted * the least with other types we defined in early drafts. It's definitely * something we should review. * @public */ export class Port extends Emitter implements GuestConnection { public get apis() { if (this.isReady() && this.guestServer) { const server = this.guestServer.getRemoteApi(); return server && this.addApiMiddleware(server.apis); } } public get metadata(): GuestMetadata { if (this.isReady() && this.guestServer) { const server = this.guestServer.getRemoteApi(); return server && server.metadata; } } // #region Properties (13) private debug: boolean; private logger?: Console; private guestServerFrame: HTMLIFrameElement; private hostApis: VirtualApi = {}; private isLoaded = false; private isGuestReady = false; private guestReadyMessageHandler: ((event: MessageEvent) => void) | null = null; private runtimeContainer: HTMLElement; private sharedContext: Record; private configuration?: Record; private subscriptions: Unsubscriber[] = []; private timeout: number; /** * If any errors occurred during the loading of guests, this property will * contain the error that was raised. * @public */ error?: Error; /** * The URL of the guest provided by the extension registry. The Host will * load this URL in the background, in the invisible the bootstrap frame, so * this URL must point to a page that calls {@link @adobe/uix-guest#register} * when it loads. */ public url: URL; public extensionPoints: string[]; private guestServer: CrossRealmObject; private guestVersion: string | undefined; // #endregion Properties (13) // #region Constructors (1) constructor(config: { owner: string; id: string; url: URL; /** * An alternate DOM element to use for invisible iframes. Will create its * own if this option is not populated with a DOM element. */ runtimeContainer: HTMLElement; options: PortOptions; logger?: Console; /** * Initial object to populate the shared context with. Once the guest * connects, it will be able to access these properties. */ sharedContext: Record; /** * A guest (extension) configuration */ configuration?: Record; /** * Guest (extension) extension points */ extensionPoints?: string[]; events: Emits; }) { super(config.id); const { timeout, debug } = { ...defaultOptions, ...(config.options || {}) }; this.timeout = timeout; this.debug = debug; this.logger = config.logger; this.id = config.id; this.url = config.url; this.runtimeContainer = config.runtimeContainer; this.sharedContext = config.sharedContext; this.configuration = config.configuration; this.extensionPoints = config.extensionPoints; this.subscriptions.push( config.events.addEventListener("contextchange", async (event) => { this.sharedContext = ( (event as CustomEvent).detail as unknown as Record ).context as Record; await this.load(); await this.guestServer .getRemoteApi() .emit("contextchange", { context: this.sharedContext }); }) ); } // #endregion Constructors (1) // #region Public Methods (6) /** * Connect an iframe element which is displaying another page in the extension * with the extension's bootstrap frame, so they can share context and events. */ public attachUI( iframe: HTMLIFrameElement, privateMethods: VirtualApi ): Promise> { return this.attachFrame(iframe, { onIframeResize: (dimensions: { height: number; width: number }) => { this.emit("guestresize", { dimensions, guestPort: this, iframe: iframe, }); }, ...privateMethods, } as UIHostMethods); } /** * Returns true if the guest has registered methods matching the provided * capability spec. A capability spec is simply an object whose properties are * declared in an array of keys, description the names of the functions and * methods that the Port will expose. */ public hasCapabilities(requiredCapabilities: CapabilitySpec) { this.assertReady(); return ( this.apis && Object.entries(requiredCapabilities).every(([apiName, methodNames]) => this.hasCapability(apiName, methodNames as string[]) ) ); } private getGuestVersion(): string { if (!this.guestVersion) { return ""; } const versionMatch = this.guestVersion.match(/\d+(\.\d+)*/); return versionMatch ? versionMatch[0] : ""; } /** * True when all extensions have loaded. */ public isReady(): boolean { const version = this.getGuestVersion(); if (version) { if (compareVersions(version, "1.1.4") >= 0) { return this.isLoaded && !this.error && this.isGuestReady; } else { return this.isLoaded && !this.error; } } return false; } /** * Loads the extension. Returns a promise which resolves when the extension * has loaded. The Host calls this method after retrieving extensions. */ public async load() { try { if (!this.isLoaded) { await this.connect(); } } catch (e) { this.guestServer = null; this.error = e instanceof Error ? e : new Error(String(e)); throw e; } } /** * The host-side equivalent of {@link @adobe/uix-guest#register}. Pass a set * of methods down to the guest as proxies. * Merges at the first level, the API level. Overwrites a deeper levels. */ public provide(apis: VirtualApi) { for (const [apiNamespace, methods] of Object.entries(apis)) { this.hostApis[apiNamespace] = this.hostApis[apiNamespace] || {}; Object.assign(this.hostApis[apiNamespace], methods); } this.emit("hostprovide", { guestPort: this, apis }); } /** * Disconnect from the extension. */ public async unload(): Promise { // Clean up guest ready message handler if it exists if (this.guestReadyMessageHandler) { window.removeEventListener("message", this.guestReadyMessageHandler); this.guestReadyMessageHandler = null; this.isGuestReady = false; } for (const unsubscribe of this.subscriptions) { if (typeof unsubscribe === "function") { unsubscribe(); } } this.subscriptions = []; if (this.guestServerFrame && this.guestServerFrame.parentElement) { this.guestServerFrame.parentElement.removeChild(this.guestServerFrame); this.guestServerFrame = undefined; } this.emit("unload", { guestPort: this }); } // #endregion Public Methods (6) // #region Private Methods (6) /** * Recursive method that wraps every function in apis object and adds * an event */ private addApiMiddleware(subject: any, path: string[] = []): VirtualApi { if (typeof subject === "object") { for (const [key, value] of Object.entries(subject)) { if (typeof value === "object") { subject[key] = this.addApiMiddleware(value, [...path, key]); } else if (isWrapperFunction(value)) { // Remote function is already wrapped. Nothing to do... continue; } else if (isFunction(value)) { const wrapper = async (...args: any) => { this.emit("beforecallguestmethod", { guestPort: this, path: [...path, key], args, }); const res = await (value as CallableFunction)(...args); /* * Wraps response in middleware so consequent calls could be intercepted * E.g. headerMenu.getButtons().onClick() */ return this.addApiMiddleware(res, [...path, key]); }; (wrapper as WrappedFunction)[WRAPPER_MARKER] = true; subject[key] = wrapper; } } } return subject; } private hasCapability(apiName: string, methodNames: string[]) { const api = this.apis[apiName]; return ( api && methodNames.every( (methodName: keyof typeof api) => Reflect.has(api, methodName) && typeof api[methodName] === "function" ) ); } private assert( condition: boolean, errorMessage: () => string ): asserts condition { if (!condition) { throw new Error( `Error in guest extension "${this.id}": ${errorMessage()}` ); } } private assertReady() { this.assert(this.isReady(), () => "Attempted to interact before loaded"); } private attachFrame( iframe: HTMLIFrameElement, addedMethods: object = {} ) { // at least this is necessary normalizeIframe(iframe); return connectIframe( iframe, { logger: this.logger, targetOrigin: this.url.origin, timeout: this.timeout, }, { getSharedContext: () => { return this.sharedContext; }, getConfiguration: () => { return this.configuration; }, invokeHostMethod: (address: HostMethodAddress) => this.invokeHostMethod(address, addedMethods as VirtualApi), ...addedMethods, }, (version: string) => { this.guestVersion = version; } ); } private async connect() { let timeoutId: ReturnType; const serverFrame = this.runtimeContainer.ownerDocument.createElement("iframe"); normalizeIframe(serverFrame); serverFrame.setAttribute("aria-hidden", "true"); serverFrame.setAttribute("src", this.url.href); this.guestServerFrame = serverFrame; this.runtimeContainer.appendChild(serverFrame); if (this.logger) { this.logger.info( `Guest ${this.id} attached iframe of ${this.url.href}`, this ); } try { this.guestServer = await this.attachFrame(serverFrame); } catch (error) { this.logger?.error( `Failed to attach guest server for ${this.id}:`, error ); throw error; } const version = this.getGuestVersion(); if (compareVersions(version, "1.1.4") >= 0) { // Only create the guest-ready listener for guests >= 1.1.4 // Older guests never send a guest-ready postMessage await new Promise((resolve, reject) => { const handleMessage = (event: MessageEvent) => { if ( event.data && event.data.type === "guest-ready" && event.source === serverFrame.contentWindow ) { this.logger?.log( `[Port ${this.id}] Received guest-ready from our iframe (guest: ${ event.data.guestId || "unknown" })` ); this.isGuestReady = true; if (this.logger) { this.logger.info(`Guest ${this.id} reported ready status`); } this.emit("guestready", { guestPort: this }); window.removeEventListener("message", handleMessage); clearTimeout(timeoutId); resolve(); } }; timeoutId = setTimeout(() => { window.removeEventListener("message", handleMessage); reject( new Error( `Guest ${this.id} did not send ready message within ${this.timeout}ms` ) ); }, this.timeout); window.addEventListener("message", handleMessage); this.guestReadyMessageHandler = handleMessage; }); } this.isLoaded = true; if (this.logger) { this.logger.info( `Guest ${this.id} established connection, received methods, and reported ready`, this.apis, this ); } } private getHostMethodCallee( { name, path }: HostMethodAddress, methodSource: VirtualApi ): VirtualApi { const dots = (level: number) => `host.${path.slice(0, level).join(".")}`; const methodCallee = path.reduce((current, prop, level) => { this.assert( Reflect.has(current, prop), () => `${dots(level)} has no property "${prop}"` ); const next = current[prop]; this.assert( typeof next === "object", () => `${dots( level )}.${prop} is not an object; namespaces must be objects with methods` ); return next as VirtualApi; }, methodSource); this.assert( typeof methodCallee[name] === "function" && Reflect.has(methodCallee, name), () => `"${dots(path.length - 1)}.${name}" is not a function` ); return methodCallee; } private invokeHostMethod( address: HostMethodAddress, privateMethods?: VirtualApi ): T { const { name, path, args = [] } = address; this.assert(name && typeof name === "string", () => "Method name required"); this.assert( path.length > 0, () => `Cannot call a method directly on the host; ".${name}()" must be in a namespace.` ); let methodCallee; if (privateMethods) { try { methodCallee = this.getHostMethodCallee(address, privateMethods); } catch (e) { // private method not found, continue and try other way of accessing it } } if (!methodCallee) { methodCallee = this.getHostMethodCallee(address, this.hostApis); } const method = methodCallee[name] as (...args: unknown[]) => T; this.emit("beforecallhostmethod", { guestPort: this, name, path, args }); return method.apply(methodCallee, [ { id: this.id, url: this.url }, ...args, ]) as T; } // #endregion Private Methods (6) }