Ver Fonte

refactor(libs): optimize message transport using MessageChannel

charlie há 2 dias atrás
pai
commit
a9f1df22ae
2 ficheiros alterados com 278 adições e 147 exclusões
  1. 2 0
      libs/src/LSPlugin.caller.ts
  2. 276 147
      libs/src/postmate/index.ts

+ 2 - 0
libs/src/LSPlugin.caller.ts

@@ -276,6 +276,8 @@ class LSPluginCaller extends EventEmitter {
       classListArray: ['lsp-iframe-sandbox'],
       model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) },
       allow: pl.options.allow,
+      // for optimized postmate message
+      enableMessageChannel: true
     })
 
     let handshake = pt.sendHandshake()

+ 276 - 147
libs/src/postmate/index.ts

@@ -1,40 +1,14 @@
 // Fork from https://github.com/dollarshaveclub/postmate
 
-/**
- * The type of messages our frames our sending
- * @type {String}
- */
 export const messageType = 'application/x-postmate-v1+json'
-
-/**
- * The maximum number of attempts to send a handshake request to the parent
- * @type {Number}
- */
+export const defaultRequestTimeout = 10_000
 export const maxHandshakeRequests = 5
 
-/**
- * A unique message ID that is used to ensure responses are sent to the correct requests
- * @type {Number}
- */
 let _messageId = 0
 
-/**
- * Increments and returns a message ID
- * @return {Number} A unique ID for a message
- */
-export const generateNewMessageId = () => ++_messageId
-
-/**
- * Postmate logging function that enables/disables via config
- */
-export const log = (...args) => (Postmate.debug ? console.log(...args) : null)
-
-/**
- * Takes a URL and returns the origin
- * @param  {String} url The full URL being requested
- * @return {String}     The URLs origin
- */
-export const resolveOrigin = (url) => {
+const generateNewMessageId = () => ++_messageId
+const log = (...args: any) => (Postmate.debug ? console.log(...args) : null)
+const resolveOrigin = (url: string) => {
   const a = document.createElement('a')
   a.href = url
   const protocol = a.protocol.length > 4 ? a.protocol : window.location.protocol
@@ -55,13 +29,7 @@ const messageTypes = {
   request: 1,
 }
 
-/**
- * Ensures that a message is safe to interpret
- * @param  {Object} message The postmate message being sent
- * @param  {String|Boolean} allowedOrigin The whitelisted origin or false to skip origin check
- * @return {Boolean}
- */
-export const sanitize = (message, allowedOrigin) => {
+export const sanitize = (message: any, allowedOrigin: any) => {
   if (typeof allowedOrigin === 'string' && message.origin !== allowedOrigin)
     return false
   if (!message.data) return false
@@ -72,14 +40,8 @@ export const sanitize = (message, allowedOrigin) => {
   return true
 }
 
-/**
- * Takes a model, and searches for a value by the property
- * @param  {Object} model     The dictionary to search against
- * @param  {String} property  A path within a dictionary (i.e. 'window.location.href')
- *                            passed to functions in the child model
- * @return {Promise}
- */
-export const resolveValue = (model, property, args) => {
+export const resolveValue = (model: any, property: string, args: Array<any>) => {
+  // args arguments passed from parent to child function
   const unwrappedContext =
     typeof model[property] === 'function'
       ? model[property].apply(null, args)
@@ -98,12 +60,42 @@ export class ParentAPI {
   public events = {}
   public childOrigin: string
   public listener: (e: any) => void
+  private readonly messagePort?: MessagePort
+
+  private addTransportListener(handler: (e: any) => void) {
+    if (this.messagePort) {
+      // MessagePort delivers MessageEvent too, but without origin/source.
+      this.messagePort.addEventListener('message', handler as any)
+      // Some browsers require start() when using addEventListener.
+      // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+      ;(this.messagePort as any).start?.()
+    } else {
+      this.parent.addEventListener('message', handler, false)
+    }
+  }
+
+  private removeTransportListener(handler: (e: any) => void) {
+    if (this.messagePort) {
+      this.messagePort.removeEventListener('message', handler as any)
+    } else {
+      this.parent.removeEventListener('message', handler, false)
+    }
+  }
+
+  private postToChild(payload: any) {
+    if (this.messagePort) {
+      this.messagePort.postMessage(payload)
+      return
+    }
+    this.child.postMessage(payload, this.childOrigin)
+  }
 
   constructor(info: Postmate) {
     this.parent = info.parent
     this.frame = info.frame
     this.child = info.child
     this.childOrigin = info.childOrigin
+    this.messagePort = info.messagePort
 
     if (process.env.NODE_ENV !== 'production') {
       log('Parent: Registering API')
@@ -111,7 +103,15 @@ export class ParentAPI {
     }
 
     this.listener = (e) => {
-      if (!sanitize(e, this.childOrigin)) return false
+      // Port messages don't have origin/source, so we only enforce postmate/type.
+      if (this.messagePort) {
+        if (!e?.data) return false
+        if (typeof e.data === 'object' && !('postmate' in e.data)) return false
+        if (e.data.type !== messageType) return false
+        if (!messageTypes[e.data.postmate]) return false
+      } else {
+        if (!sanitize(e, this.childOrigin)) return false
+      }
 
       /**
        * the assignments below ensures that e, data, and value are all defined
@@ -123,26 +123,33 @@ export class ParentAPI {
           log(`Parent: Received event emission: ${name}`)
         }
         if (name in this.events) {
-          this.events[name].forEach((callback) => {
+          this.events[name].forEach((callback: Function) => {
             callback.call(this, data)
           })
         }
       }
     }
 
-    this.parent.addEventListener('message', this.listener, false)
+    this.addTransportListener(this.listener)
     if (process.env.NODE_ENV !== 'production') {
       log('Parent: Awaiting event emissions from Child')
     }
   }
 
-  get(property, ...args) {
+  get(property: string, ...args: any) {
     return new Promise((resolve, reject) => {
       // Extract data from response and kill listeners
       const uid = generateNewMessageId()
+      const timeoutMs =
+        typeof (Postmate as any).requestTimeout === 'number'
+          ? (Postmate as any).requestTimeout
+          : defaultRequestTimeout
+
+      let timer: any
       const transact = (e) => {
-        if (e.data.uid === uid && e.data.postmate === 'reply') {
-          this.parent.removeEventListener('message', transact, false)
+        if (e?.data?.uid === uid && e.data.postmate === 'reply') {
+          this.removeTransportListener(transact)
+          if (timer) clearTimeout(timer)
           if (e.data.error) {
             reject(e.data.error)
           } else {
@@ -152,36 +159,37 @@ export class ParentAPI {
       }
 
       // Prepare for response from Child...
-      this.parent.addEventListener('message', transact, false)
+      this.addTransportListener(transact)
+
+      if (timeoutMs > 0) {
+        timer = setTimeout(() => {
+          this.removeTransportListener(transact)
+          reject(new Error(`Postmate: request timeout (${timeoutMs}ms)`))
+        }, timeoutMs)
+      }
 
       // Then ask child for information
-      this.child.postMessage(
-        {
-          postmate: 'request',
-          type: messageType,
-          property,
-          args,
-          uid,
-        },
-        this.childOrigin
-      )
+      this.postToChild({
+        postmate: 'request',
+        type: messageType,
+        property,
+        args,
+        uid,
+      })
     })
   }
 
-  call(property, data) {
+  call(property: string, data: any) {
     // Send information to the child
-    this.child.postMessage(
-      {
-        postmate: 'call',
-        type: messageType,
-        property,
-        data,
-      },
-      this.childOrigin
-    )
+    this.postToChild({
+      postmate: 'call',
+      type: messageType,
+      property,
+      data,
+    })
   }
 
-  on(eventName, callback) {
+  on(eventName: string, callback: Function) {
     if (!this.events[eventName]) {
       this.events[eventName] = []
     }
@@ -192,37 +200,69 @@ export class ParentAPI {
     if (process.env.NODE_ENV !== 'production') {
       log('Parent: Destroying Postmate instance')
     }
-    window.removeEventListener('message', this.listener, false)
+    this.removeTransportListener(this.listener)
+    try {
+      this.messagePort?.close()
+    } catch (e) {
+      // ignore
+    }
     this.frame.parentNode.removeChild(this.frame)
   }
 }
 
-/**
- * Composes an API to be used by the child
- * @param {Object} info Information on the consumer
- */
+// Composes an API to be used by the child
 export class ChildAPI {
-  private model: any
+  private readonly model: any
   private parent: Window
-  private parentOrigin: string
+  private readonly parentOrigin: string
   private child: Window
+  private readonly messagePort?: MessagePort
+  private readonly listener: (e: any) => void
+
+  private addTransportListener(handler: (e: any) => void) {
+    if (this.messagePort) {
+      this.messagePort.addEventListener('message', handler as any)
+      // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+      ;(this.messagePort as any).start?.()
+    } else {
+      this.child.addEventListener('message', handler, false)
+    }
+  }
+
+  private postToParent(payload: any, fallbackEvent?: MessageEvent<any>) {
+    if (this.messagePort) {
+      this.messagePort.postMessage(payload)
+      return
+    }
+    // reply uses the event source/origin, others use stored parentOrigin.
+    if (fallbackEvent?.source) {
+      ;(fallbackEvent.source as WindowProxy).postMessage(payload, fallbackEvent.origin)
+    } else {
+      this.parent.postMessage(payload, this.parentOrigin)
+    }
+  }
 
   constructor(info: Model) {
     this.model = info.model
     this.parent = info.parent
     this.parentOrigin = info.parentOrigin
     this.child = info.child
+    this.messagePort = info.messagePort
 
     if (process.env.NODE_ENV !== 'production') {
       log('Child: Registering API')
       log('Child: Awaiting messages...')
     }
 
-    this.child.addEventListener('message', (e) => {
-      if (!sanitize(e, this.parentOrigin)) return
-
-      if (process.env.NODE_ENV !== 'production') {
-        log('Child: Received request', e.data)
+    this.listener = (e) => {
+      // Port messages don't have origin/source, so we only enforce postmate/type.
+      if (this.messagePort) {
+        if (!e?.data) return
+        if (typeof e.data === 'object' && !('postmate' in e.data)) return
+        if (e.data.type !== messageType) return
+        if (!messageTypes[e.data.postmate]) return
+      } else {
+        if (!sanitize(e, this.parentOrigin)) return
       }
 
       const { property, uid, data, args } = e.data
@@ -238,47 +278,48 @@ export class ChildAPI {
       }
 
       // Reply to Parent
-      resolveValue(this.model, property, args).then((value) => {
-        ;(e.source as WindowProxy).postMessage(
-          {
-            property,
-            postmate: 'reply',
-            type: messageType,
-            uid,
-            value,
-          },
-          e.origin
-        )
-      }).catch((error) => {
-        ;(e.source as WindowProxy).postMessage(
-          {
-            property,
-            postmate: 'reply',
-            type: messageType,
-            uid,
-            error,
-          },
-          e.origin
-        )
-      })
-    })
+      resolveValue(this.model, property, args)
+        .then((value) => {
+          this.postToParent(
+            {
+              property,
+              postmate: 'reply',
+              type: messageType,
+              uid,
+              value,
+            },
+            e
+          )
+        })
+        .catch((error) => {
+          this.postToParent(
+            {
+              property,
+              postmate: 'reply',
+              type: messageType,
+              uid,
+              error,
+            },
+            e
+          )
+        })
+    }
+
+    this.addTransportListener(this.listener)
   }
 
-  emit(name, data) {
+  emit(name: string, data: any) {
     if (process.env.NODE_ENV !== 'production') {
       log(`Child: Emitting Event "${name}"`, data)
     }
-    this.parent.postMessage(
-      {
-        postmate: 'emit',
-        type: messageType,
-        value: {
-          name,
-          data,
-        },
+    this.postToParent({
+      postmate: 'emit',
+      type: messageType,
+      value: {
+        name,
+        data,
       },
-      this.parentOrigin
-    )
+    })
   }
 }
 
@@ -290,6 +331,11 @@ export type PostMateOptions = {
   name?: string
   model?: any,
   allow?: string
+  /**
+   * Prefer using MessageChannel/MessagePort after handshake.
+   * Defaults to false to keep backward-compatible behavior with older SDKs.
+   */
+  enableMessageChannel?: boolean
 }
 
 /**
@@ -297,6 +343,7 @@ export type PostMateOptions = {
  */
 export class Postmate {
   static debug = false // eslint-disable-line no-undef
+  static requestTimeout: number = defaultRequestTimeout
   public container?: HTMLElement
   public parent: Window
   public frame: HTMLIFrameElement
@@ -306,9 +353,10 @@ export class Postmate {
   public model: any
   static Model: any
 
-  /**
-   * @param opts
-   */
+  // Preferred transport after handshake.
+  public messagePort?: MessagePort
+  private readonly enableMessageChannel: boolean
+
   constructor(opts: PostMateOptions) {
     this.container = opts.container
     this.url = opts.url
@@ -324,19 +372,42 @@ export class Postmate {
     this.container.appendChild(this.frame)
     this.child = this.frame.contentWindow
     this.model = opts.model || {}
+    this.enableMessageChannel = !!opts.enableMessageChannel
   }
 
-  /**
-   * Begins the handshake strategy
-   * @param  {String} url The URL to send a handshake request to
-   * @return {Promise}     Promise that resolves when the handshake is complete
-   */
   sendHandshake(url?: string) {
     url = url || this.url
     const childOrigin = resolveOrigin(url)
     let attempt = 0
-    let responseInterval
+    let responseInterval: any
     return new Promise((resolve, reject) => {
+      const runtimeSupportsMessageChannel =
+        typeof MessageChannel !== 'undefined' &&
+        typeof (MessageChannel as any) === 'function'
+
+      const shouldUseMessageChannel =
+        this.enableMessageChannel && runtimeSupportsMessageChannel
+
+      // Prefer MessageChannel if available. We transfer port2 to child.
+      // Important: once a port is transferred, it becomes neutered in this context,
+      // so we must create a fresh channel per handshake attempt.
+      let channel: MessageChannel | null = null
+      let port1: MessagePort | undefined
+      let port2: MessagePort | undefined
+
+      const ensureChannel = () => {
+        if (!shouldUseMessageChannel) return
+        // If we already have an active port from previous run, keep it.
+        if (this.messagePort) return
+        channel = new MessageChannel()
+        port1 = channel.port1
+        port2 = channel.port2
+        this.messagePort = port1
+        ;(port1 as any).start?.()
+      }
+
+      ensureChannel()
+
       const reply = (e: any) => {
         if (!sanitize(e, childOrigin)) return false
         if (e.data.postmate === 'handshake-reply') {
@@ -346,6 +417,24 @@ export class Postmate {
           }
           this.parent.removeEventListener('message', reply, false)
           this.childOrigin = e.origin
+
+          // If child didn't accept/return channel, fallback to window messages.
+          // Note: MessageChannel port is already set on this instance.
+          // Some browsers deliver the transferred port via e.ports.
+          if (e?.ports?.length) {
+            // Prefer the port child returned (if any); otherwise keep existing.
+            const returnedPort = e.ports[0]
+            if (returnedPort) {
+              try {
+                this.messagePort?.close()
+              } catch (err) {
+                // ignore
+              }
+              this.messagePort = returnedPort
+              ;(this.messagePort as any).start?.()
+            }
+          }
+
           if (process.env.NODE_ENV !== 'production') {
             log('Parent: Saving Child origin', this.childOrigin)
           }
@@ -367,14 +456,40 @@ export class Postmate {
         if (process.env.NODE_ENV !== 'production') {
           log(`Parent: Sending handshake attempt ${attempt}`, { childOrigin })
         }
-        this.child.postMessage(
-          {
-            postmate: 'handshake',
-            type: messageType,
-            model: this.model,
-          },
-          childOrigin
-        )
+        // port2 can be transferred only once. Create a new channel for retries.
+        if (shouldUseMessageChannel) {
+          // close any previous un-used port before re-creating.
+          if (!this.messagePort || port2 === undefined) {
+            // nothing
+          }
+          if (!port2) {
+            // We already transferred it in a previous attempt; create a fresh pair.
+            try {
+              this.messagePort?.close()
+            } catch (e) {
+              // ignore
+            }
+            this.messagePort = undefined
+            ensureChannel()
+          }
+        }
+
+        const payload: any = {
+          postmate: 'handshake',
+          type: messageType,
+          model: this.model,
+          // hint for debugging / future extension
+          channel: port2 ? 1 : 0,
+          enableMessageChannel: shouldUseMessageChannel ? 1 : 0,
+        }
+
+        if (port2) {
+          this.child.postMessage(payload, childOrigin, [port2])
+          // Mark as transferred; next retry needs a new channel.
+          port2 = undefined
+        } else {
+          this.child.postMessage(payload, childOrigin)
+        }
 
         if (attempt === maxHandshakeRequests) {
           clearInterval(responseInterval)
@@ -399,6 +514,11 @@ export class Postmate {
     if (process.env.NODE_ENV !== 'production') {
       log('Postmate: Destroying Postmate instance')
     }
+    try {
+      this.messagePort?.close()
+    } catch (e) {
+      // ignore
+    }
     this.frame.parentNode.removeChild(this.frame)
   }
 }
@@ -411,22 +531,17 @@ export class Model {
   public model: any
   public parent: Window
   public parentOrigin: string
+  public messagePort?: MessagePort
+  private enableMessageChannel: boolean
 
-  /**
-   * Initializes the child, model, parent, and responds to the Parents handshake
-   * @param {Object} model Hash of values, functions, or promises
-   * @return {Promise}       The Promise that resolves when the handshake has been received
-   */
-  constructor(model) {
+  constructor(model: any) {
     this.child = window
     this.model = model
     this.parent = this.child.parent
+    // Child side is controlled by what parent sends in handshake; default false.
+    this.enableMessageChannel = false
   }
 
-  /**
-   * Responds to a handshake initiated by the Parent
-   * @return {Promise} Resolves an object that exposes an API for the Child
-   */
   sendHandshakeReply() {
     return new Promise((resolve, reject) => {
       const shake = (e: MessageEvent<any>) => {
@@ -438,9 +553,23 @@ export class Model {
             log('Child: Received handshake from Parent')
           }
           this.child.removeEventListener('message', shake, false)
+
+          // Only enable MessagePort transport when parent explicitly opted-in AND actually transferred a port.
+          this.enableMessageChannel = !!e.data?.enableMessageChannel
+          const transferredPort = e?.ports?.[0]
+          if (this.enableMessageChannel && transferredPort) {
+            this.messagePort = transferredPort
+            ;(this.messagePort as any).start?.()
+          } else {
+            this.messagePort = undefined
+          }
+
           if (process.env.NODE_ENV !== 'production') {
             log('Child: Sending handshake reply to Parent')
           }
+          // Reply back. If we want a dedicated child->parent port, we can
+          // create one and transfer it. For now, we only ack and rely on the
+          // transferred port (if any). Keep window reply for compatibility.
           ;(e.source as WindowProxy).postMessage(
             {
               postmate: 'handshake-reply',