Skip to content

Instantly share code, notes, and snippets.

@literallylara
Last active May 6, 2022 10:22
Show Gist options
  • Save literallylara/90fa80dcebe88d75e550e0cb9f16a30c to your computer and use it in GitHub Desktop.
Save literallylara/90fa80dcebe88d75e550e0cb9f16a30c to your computer and use it in GitHub Desktop.
/**
* A `Window.postMessage()` wrapper that aims to simplify the process of
* having two windows communicate with each other (see below for more details).
*
* @author Lara Sophie Schütt (@literallylara)
* @license MIT
* @version 1.0.5
*/
const SYNC_STATE_IDLE = 0b00000
const SYNC_STATE_SENT = 0b00001
const SYNC_STATE_RECEIVED = 0b00010
const SYNC_STATE_REPLIED = 0b00100
const SYNC_STATE_FINISHED = 0b01000
const SYNC_STATE_FAILED = 0b10000
class WindowCoupler
{
#requests = []
#events = []
#syncState = SYNC_STATE_IDLE
#syncLog = []
#syncPromise
#syncResolve
#syncReject
#syncId
#syncData
#syncTimeout
#syncTimestamp
#syncInterval
#targetWindow
#targetOrigin
#pairingCode
#api
#debug
/**
* A `Window.postMessage()` wrapper that aims to simplify the process of
* having two windows communicate with each other. It can automatically find
* the target window by specifying a pairing code. Furthermore it enables
* listening for custom events on the target window as well as requesting
* properties and calling functions via an optionally supplied API object.
*
* # Example
* ## Window A
* ```js
* const coupler = new WindowCoupler("<pairingCode>")
*
* coupler.sync().then(() =>
* {
* coupler.request("add", 10, 5).then(result =>
* {
* console.log(result)
* })
* })
*
* coupler.on("chat", msg => console.log(msg))
* ```
* ## Window B
* ```js
* const api = {
* add(a, b)
* {
* return a + b
* }
* }
*
* const coupler = new WindowCoupler("<pairingCode>", { api })
*
* coupler.sync().then(() =>
* {
* coupler.trigger("chat", "Hello!")
* })
* ```
* @param {string} pairingCode
* A pairing code that both windows have agreed upon
* @param {object} [options]
* The following options are available:
* - api - An object for handling requests from the target window,
* see `WindowCoupler.request()` for details
* - targetOrigin - Supplying a target origin avoids sending messages
* to other windows during the synchronisation stage. Defaults to `"*"`
* - targetWindow - if the target window is already known it can be specified here
* - debug - Whether or not to log debug information. Defaults to `false`
* @param {object} [options.api]
* @param {string} [options.targetOrigin="*"]
* @param {boolean} [options.debug=false]
*/
constructor(pairingCode, { api, targetOrigin, targetWindow, debug } = {})
{
this.#pairingCode = pairingCode
this.#targetOrigin = targetOrigin || '*'
this.#targetWindow = targetWindow
this.#api = api
this.#debug = debug
this.#syncLog.push(['idle', Date.now()])
window.addEventListener('message', this.#onMessage.bind(this))
}
#log(label, ...args)
{
if (!this.#debug) return
const href = window.location.href
label = `WindowCoupler (${this.#pairingCode} @ ${href}) : ${label}`
console.groupCollapsed(label)
console.log(...args)
console.trace()
console.groupEnd()
}
#onMessage(e)
{
// ignore messages initiated by this window
if (e.source === window) return
// message source is not our known target window
if (this.#targetWindow && this.#targetWindow !== e.source) return
// messages comes from the wrong origin
if (this.#targetOrigin !== '*' && e.origin !== this.#targetOrigin) return
let msg = e.data
// convert to plain object
if (!isPlainObject(msg))
{
try { msg = JSON.parse(msg) }
catch { return }
}
// message has the wrong signature
if (msg.pairingCode !== this.#pairingCode) return
// all tests passed, this must be our target window
if (!this.#targetWindow)
{
this.#targetWindow = e.source
}
switch (msg.action)
{
case 'sync' : this.#onSync(msg) ; break
case 'trigger' : this.#onTrigger(msg) ; break
case 'request' : this.#onRequest(msg) ; break
case 'respond' : this.#onRespond(msg) ; break
}
}
/**
* Updates the sync state and calls `settleSyncPromiseIfReady()`
* @param {{ data }} msg
*/
#onSync(msg)
{
this.#log('#onSync', msg)
if (msg.meta !== this.#syncId)
{
this.#syncData = msg.data
this.#message('sync', null, msg.meta)
this.#syncState |= SYNC_STATE_REPLIED
this.#syncLog.push(['replied', Date.now()])
}
else
{
this.#syncState |= SYNC_STATE_RECEIVED
this.#syncLog.push(['received', Date.now()])
this.#settleSyncPromiseIfReady()
}
}
/**
* Fires all callbacks for the event specified in `msg.meta`.
* @param {{ meta, data }} msg
*/
#onTrigger(msg)
{
this.#log('#onTrigger', msg)
const events = this.#events[msg.meta]
events?.forEach(event => event.callback(msg.data))
this.#events[msg.meta] = events?.filter(event => !event.once)
}
/**
* Evaluates the requested property and sends a response message.
* @param {{ meta: string, data: [string, ...any] }} msg
* The request message object must have the following properties:
* - `meta` - Stores the request id
* - `data` - An array containing the property chain and optionally
* a set of arguments
*
* For example, if
* ```js
* this.#api.foo.bar[0].baz
* ```
* is a function, then
* ```js
* ["foo.bar.0.baz", 10, false]
* ```
* will result in
* ```js
* this.#api.foo.bar[0].baz(10, false)
* ```
* being evaluated and sent in the response.
*/
#onRequest(msg)
{
this.#log('#onRequest', msg)
if (!this.#api) return
const props = msg.data[0].split('.')
const args = msg.data.slice(1)
let obj = this.#api
props.forEach(key =>
{
obj = isNaN(key) ? obj[key] : obj[+key]
})
const id = msg.meta
if (typeof obj === 'function')
{
try
{
obj = obj.apply(this.#api, args)
}
catch (error)
{
this.#message('respond', null,
{
id,
error: error.toString()
})
return
}
if (isPromiseLike(obj))
{
obj
.then(v => this.#message('respond', v, { id }))
.catch(error =>
{
this.#message('respond', null,
{
id,
error: error.toString()
})
})
}
else
{
this.#message('respond', obj, { id })
}
}
else
{
this.#message('respond', obj, { id })
}
}
/**
* Processes a reponse message by resolving or rejecting
* the corresponding request's promise.
* @param {{ meta, data, error }} msg
* The request message object has the following properties:
* - `meta` - Stores the request id
* - `data` - (optional) Variable of any type to be resolved with
* - `error` - (optional) If present, rejects the request's promise
* with that value
*/
#onRespond(msg)
{
this.#log('#onRespond', msg)
const { id, error } = msg.meta
const request = this.#requests[id]
if (error)
{
request.reject(error)
}
else
{
request.resolve(msg.data)
}
}
/**
* Sends an object composed of `{ pairingCode, action, meta, data }` to
* the target window. If the target window has not been established yet
* as part of the synchronisation stage, the message will be sent to all
* windows matching the target origin specified in the constructor.
* @param {string} action
* @param {any} data
* @param {any} meta
*/
#message(action, data, meta)
{
if (this.#targetWindow)
{
this.#targetWindow.postMessage({
pairingCode: this.#pairingCode,
action, meta, data
}, this.#targetOrigin)
return
}
const frames = Array.from(window.frames)
const iframes = Array.from(document.querySelectorAll('iframe'))
frames.unshift(window.top)
frames
.filter(v =>
{
return v && v !== window && 'postMessage' in v
})
.forEach(frame =>
{
const msg = {
pairingCode: this.#pairingCode,
action, meta, data
}
try
{
if (this.#targetOrigin === '*')
{
frame.postMessage(JSON.stringify(msg), '*')
}
else
{
const iframe = iframes.find(v => v.contentWindow === frame)
const origin = iframe ? new URL(iframe.src).origin : frame.origin
// if frame.origin is not accessible due to CORS,
// an error will be thrown which can be caught,
// other than the postMessage error which cannot be caught
if (origin === this.#targetOrigin)
{
frame.postMessage(JSON.stringify(msg), this.#targetOrigin)
}
}
}
catch (error)
{
void(0)
}
})
}
/**
* Checks if the synchronisation stage is finished and if that is the case
* resolves or rejects (in case of timeout) the `#syncPromise`.
*/
#settleSyncPromiseIfReady()
{
if (this.#syncState & SYNC_STATE_FINISHED) return
this.#log('#settleSyncPromiseIfReady (0/1)', this.#syncLog)
if (this.#syncState & SYNC_STATE_SENT
&& this.#syncState & SYNC_STATE_RECEIVED)
{
this.#syncState |= SYNC_STATE_FINISHED
this.#syncLog.push(['finished', Date.now()])
}
if (!(this.#syncState & SYNC_STATE_FINISHED)) return
if (this.#syncState & SYNC_STATE_FAILED) return
const d = Date.now() - this.#syncTimestamp
if (this.#syncTimeout === null || d < this.#syncTimeout)
{
this.#syncResolve(this.#syncData)
this.#log('#settleSyncPromiseIfReady (1/1)', this.#syncLog)
}
else
{
this.#syncState |= SYNC_STATE_FAILED
this.#syncLog.push(['failed', Date.now()])
this.#syncReject()
this.#log('#settleSyncPromiseIfReady (1/1)', this.#syncLog)
}
}
/**
* Tells the target window that it is ready to receive messages.
* @param {any} [data]
* Optional data to send with the sync request that will determine
* the resolved value on the target window.
* @param {object} [options]
* The following options are available:
* - timeout - Timeout in milliseconds after which a sync attempt is to be considered
* as failed and the returned promise will reject. Defaults to `null` which
* means no timeout is applied.
* - interval - Interval in milliseconds at which to resend
* the sync message. This is useful for when not all windows/frames have
* loaded when this method is called for the first time. Defaults to `1000`
* @returns {Promise<any>}
* A promise that resolves once both windows have successfully
* found each other and are ready to communicate. The resolved value
* is equal to the `data` sent from the target window, if any.
*/
sync(data, { timeout = null, interval = 1000 } = {})
{
if (this.#syncPromise) return
this.#log('sync', data, timeout, interval)
const [promise, resolve, reject] = new UnwrappedPromise()
this.#syncTimeout = timeout
this.#syncTimestamp = Date.now()
this.#syncResolve = resolve
this.#syncReject = reject
this.#syncPromise = promise
this.#syncId = uid(6)
this.#message('sync', data, this.#syncId)
this.#syncState |= SYNC_STATE_SENT
this.#syncLog.push(['sent', Date.now()])
this.#syncInterval = window.setInterval(() =>
{
const d = Date.now() - this.#syncTimestamp
if (this.#syncState & SYNC_STATE_FINISHED
|| this.#syncTimeout && (d > this.#syncTimeout))
{
window.clearInterval(this.#syncInterval)
return
}
this.#message('sync', data, this.#syncId)
}, interval)
return promise
}
/**
* Sends a request to the target window. Each argument is considered
* a property on the target window's API (as supplied in the constructor).
* If the final property is a function, the promise returned from this
* method will resolve with its return value, otherwise it will resolve with
* the requested property. Promises will be settled first before resolving.
*
* ## Example
* If this window calls `coupler.request("foo", "bar")`,
* then on the target window's end `api.foo.bar` will be requested/called.
* @returns {Promise}
* A promise that resolves with the requested property.
* If the requested property is a promise then this method will
* wait for it to settle first and then resolve or reject
* based on the promise's outcome.
*/
request()
{
this.#log('request', arguments)
const id = uid(16)
const [promise, resolve, reject] = new UnwrappedPromise()
this.#requests[id] = { resolve, reject }
this.#message('request', Array.from(arguments), id)
return promise
}
/**
* Registers an event handler for the given event on the target window.
* If the target window calls `WindowCoupler.trigger()` with the same event,
* `callback` will be fired.
* @param {string} event
* @param {function} callback
* @param {boolean} [once=false]
* Whether or not to fire the callback only once. Defaults to `false`.
*/
on(event, callback, once = false)
{
this.#log('on', event, callback, once)
if (!this.#events[event])
{
this.#events[event] = []
}
this.#events[event].push({ callback, once })
}
/**
* Same as `WindowCoupler.on()` but the callback will only be fired once.
* @param {string} event
* @param {function} callback
*/
once(event, callback)
{
this.#log('once', event, callback)
return this.on(event, callback, true)
}
/**
* Removes a previously defined event listener.
* If a callback is provided, only the listener with that same callback
* will be removed, otherwise all listeners for that event will be removed.
* @param {string} event
* @param {function} [callback]
*/
off(event, callback)
{
this.#log('off', event, callback)
if (!this.#events[event]) return
if (callback)
{
const i = this.#events[event].findIndex(v => v === callback)
if (i !== -1)
{
this.#events[event].splice(i,1)
}
}
else
{
this.#events[event] = []
}
}
/**
* Triggers a custom event on the target window.
* @param {string} event
* The event to be triggered
* @param {any} [data]
* Data that will be provided to the event listeners
*/
trigger(event, data)
{
this.#log('trigger', event, data)
this.#message('trigger', data, event)
}
/**
* @returns {string}
* The sync history with delta times in the form of:
* ```js
* "@ <delta> | <state>"
* ```
* Possible states are:
* - idle
* - sent
* - received
* - replied
* - finished
* - failed
*/
getSyncLog()
{
return this.#syncLog.slice(0).map(v =>
{
const d = v[1] - this.#syncTimestamp
return `@ ${d.toString().padStart(4,0)} ms | ${v[0]}`
}).join('\n')
}
}
class UnwrappedPromise
{
constructor()
{
let resolve = null
let reject = null
const promise = new Promise((res, rej) =>
{
resolve = res
reject = rej
})
return [promise, resolve, reject]
}
}
function uid(length)
{
let str = ''
for (let i = 0; i < length; i++)
{
const c = (Math.random()*36|0).toString(36)
str += Math.random() > 0.5 ? c.toUpperCase() : c
}
return str
}
function isPlainObject(o)
{
return Object.prototype.toString.call(o) === '[object Object]'
}
function isPromiseLike(v)
{
try
{
return v instanceof Promise || ('then' in v && 'catch' in v)
}
catch (err)
{
return false
}
}
export default WindowCoupler
@literallylara
Copy link
Author

literallylara commented May 4, 2022

Example

Window A

const coupler = new WindowCoupler("<pairingCode>")

coupler.sync().then(() =>
{
    coupler.request("add", 10, 5).then(result =>
    {
        console.log(result)
    })
})

coupler.on("chat", msg => console.log(msg))

Window B

const api = {
    add(a, b)
    {
        return a + b
    }
}

const coupler = new WindowCoupler("<pairingCode>", { api })

coupler.sync().then(() =>
{
    coupler.trigger("chat", "Hello!")
})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment