Last active
May 6, 2022 10:22
-
-
Save literallylara/90fa80dcebe88d75e550e0cb9f16a30c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example
Window A
Window B