index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. // Fork from https://github.com/dollarshaveclub/postmate
  2. /**
  3. * The type of messages our frames our sending
  4. * @type {String}
  5. */
  6. export const messageType = 'application/x-postmate-v1+json'
  7. /**
  8. * The maximum number of attempts to send a handshake request to the parent
  9. * @type {Number}
  10. */
  11. export const maxHandshakeRequests = 5
  12. /**
  13. * A unique message ID that is used to ensure responses are sent to the correct requests
  14. * @type {Number}
  15. */
  16. let _messageId = 0
  17. /**
  18. * Increments and returns a message ID
  19. * @return {Number} A unique ID for a message
  20. */
  21. export const generateNewMessageId = () => ++_messageId
  22. /**
  23. * Postmate logging function that enables/disables via config
  24. */
  25. export const log = (...args) => Postmate.debug ? console.log(...args) : null
  26. /**
  27. * Takes a URL and returns the origin
  28. * @param {String} url The full URL being requested
  29. * @return {String} The URLs origin
  30. */
  31. export const resolveOrigin = (url) => {
  32. const a = document.createElement('a')
  33. a.href = url
  34. const protocol = a.protocol.length > 4 ? a.protocol : window.location.protocol
  35. const host = a.host.length ? ((a.port === '80' || a.port === '443') ? a.hostname : a.host) : window.location.host
  36. return a.origin || `${protocol}//${host}`
  37. }
  38. const messageTypes = {
  39. handshake: 1,
  40. 'handshake-reply': 1,
  41. call: 1,
  42. emit: 1,
  43. reply: 1,
  44. request: 1,
  45. }
  46. /**
  47. * Ensures that a message is safe to interpret
  48. * @param {Object} message The postmate message being sent
  49. * @param {String|Boolean} allowedOrigin The whitelisted origin or false to skip origin check
  50. * @return {Boolean}
  51. */
  52. export const sanitize = (message, allowedOrigin) => {
  53. if (
  54. typeof allowedOrigin === 'string' &&
  55. message.origin !== allowedOrigin
  56. ) return false
  57. if (!message.data) return false
  58. if (
  59. typeof message.data === 'object' &&
  60. !('postmate' in message.data)
  61. ) return false
  62. if (message.data.type !== messageType) return false
  63. if (!messageTypes[message.data.postmate]) return false
  64. return true
  65. }
  66. /**
  67. * Takes a model, and searches for a value by the property
  68. * @param {Object} model The dictionary to search against
  69. * @param {String} property A path within a dictionary (i.e. 'window.location.href')
  70. * passed to functions in the child model
  71. * @return {Promise}
  72. */
  73. export const resolveValue = (model, property) => {
  74. const unwrappedContext = typeof model[property] === 'function'
  75. ? model[property]() : model[property]
  76. return Promise.resolve(unwrappedContext)
  77. }
  78. /**
  79. * Composes an API to be used by the parent
  80. * @param {Object} info Information on the consumer
  81. */
  82. export class ParentAPI {
  83. public parent: Window
  84. public frame: HTMLIFrameElement
  85. public child: Window
  86. public events = {}
  87. public childOrigin: string
  88. public listener: (e: any) => void
  89. constructor (info: Postmate) {
  90. this.parent = info.parent
  91. this.frame = info.frame
  92. this.child = info.child
  93. this.childOrigin = info.childOrigin
  94. if (process.env.NODE_ENV !== 'production') {
  95. log('Parent: Registering API')
  96. log('Parent: Awaiting messages...')
  97. }
  98. this.listener = (e) => {
  99. if (!sanitize(e, this.childOrigin)) return false
  100. /**
  101. * the assignments below ensures that e, data, and value are all defined
  102. */
  103. const { data, name } = (((e || {}).data || {}).value || {})
  104. if (e.data.postmate === 'emit') {
  105. if (process.env.NODE_ENV !== 'production') {
  106. log(`Parent: Received event emission: ${name}`)
  107. }
  108. if (name in this.events) {
  109. this.events[name].forEach(callback => {
  110. callback.call(this, data)
  111. })
  112. }
  113. }
  114. }
  115. this.parent.addEventListener('message', this.listener, false)
  116. if (process.env.NODE_ENV !== 'production') {
  117. log('Parent: Awaiting event emissions from Child')
  118. }
  119. }
  120. get (property) {
  121. return new Promise((resolve) => {
  122. // Extract data from response and kill listeners
  123. const uid = generateNewMessageId()
  124. const transact = (e) => {
  125. if (e.data.uid === uid && e.data.postmate === 'reply') {
  126. this.parent.removeEventListener('message', transact, false)
  127. resolve(e.data.value)
  128. }
  129. }
  130. // Prepare for response from Child...
  131. this.parent.addEventListener('message', transact, false)
  132. // Then ask child for information
  133. this.child.postMessage({
  134. postmate: 'request',
  135. type: messageType,
  136. property,
  137. uid,
  138. }, this.childOrigin)
  139. })
  140. }
  141. call (property, data) {
  142. // Send information to the child
  143. this.child.postMessage({
  144. postmate: 'call',
  145. type: messageType,
  146. property,
  147. data,
  148. }, this.childOrigin)
  149. }
  150. on (eventName, callback) {
  151. if (!this.events[eventName]) {
  152. this.events[eventName] = []
  153. }
  154. this.events[eventName].push(callback)
  155. }
  156. destroy () {
  157. if (process.env.NODE_ENV !== 'production') {
  158. log('Parent: Destroying Postmate instance')
  159. }
  160. window.removeEventListener('message', this.listener, false)
  161. this.frame.parentNode.removeChild(this.frame)
  162. }
  163. }
  164. /**
  165. * Composes an API to be used by the child
  166. * @param {Object} info Information on the consumer
  167. */
  168. export class ChildAPI {
  169. private model: any
  170. private parent: Window
  171. private parentOrigin: string
  172. private child: Window
  173. constructor (info: Model) {
  174. this.model = info.model
  175. this.parent = info.parent
  176. this.parentOrigin = info.parentOrigin
  177. this.child = info.child
  178. if (process.env.NODE_ENV !== 'production') {
  179. log('Child: Registering API')
  180. log('Child: Awaiting messages...')
  181. }
  182. this.child.addEventListener('message', (e) => {
  183. if (!sanitize(e, this.parentOrigin)) return
  184. if (process.env.NODE_ENV !== 'production') {
  185. log('Child: Received request', e.data)
  186. }
  187. const { property, uid, data } = e.data
  188. if (e.data.postmate === 'call') {
  189. if (property in this.model && typeof this.model[property] === 'function') {
  190. this.model[property](data)
  191. }
  192. return
  193. }
  194. // Reply to Parent
  195. resolveValue(this.model, property)
  196. .then(value => {
  197. // @ts-ignore
  198. e.source.postMessage({
  199. property,
  200. postmate: 'reply',
  201. type: messageType,
  202. uid,
  203. value,
  204. }, e.origin)
  205. })
  206. })
  207. }
  208. emit (name, data) {
  209. if (process.env.NODE_ENV !== 'production') {
  210. log(`Child: Emitting Event "${name}"`, data)
  211. }
  212. this.parent.postMessage({
  213. postmate: 'emit',
  214. type: messageType,
  215. value: {
  216. name,
  217. data,
  218. },
  219. }, this.parentOrigin)
  220. }
  221. }
  222. export type PostMateOptions = {
  223. container: HTMLElement
  224. url: string
  225. classListArray?: Array<string>
  226. name?: string
  227. model?: any
  228. }
  229. /**
  230. * The entry point of the Parent.
  231. */
  232. export class Postmate {
  233. static debug = false // eslint-disable-line no-undef
  234. public container?: HTMLElement
  235. public parent: Window
  236. public frame: HTMLIFrameElement
  237. public child?: Window
  238. public childOrigin?: string
  239. public url: string
  240. public model: any
  241. static Model: any
  242. /**
  243. * @param opts
  244. */
  245. constructor (opts: PostMateOptions) {
  246. this.container = opts.container
  247. this.url = opts.url
  248. this.parent = window
  249. this.frame = document.createElement('iframe')
  250. this.frame.name = opts.name || ''
  251. this.frame.classList.add.apply(this.frame.classList, opts.classListArray || [])
  252. this.container.appendChild(this.frame)
  253. this.child = this.frame.contentWindow
  254. this.model = opts.model || {}
  255. }
  256. /**
  257. * Begins the handshake strategy
  258. * @param {String} url The URL to send a handshake request to
  259. * @return {Promise} Promise that resolves when the handshake is complete
  260. */
  261. sendHandshake (url?: string) {
  262. url = url || this.url
  263. const childOrigin = resolveOrigin(url)
  264. let attempt = 0
  265. let responseInterval
  266. return new Promise((resolve, reject) => {
  267. const reply = (e: any) => {
  268. if (!sanitize(e, childOrigin)) return false
  269. if (e.data.postmate === 'handshake-reply') {
  270. clearInterval(responseInterval)
  271. if (process.env.NODE_ENV !== 'production') {
  272. log('Parent: Received handshake reply from Child')
  273. }
  274. this.parent.removeEventListener('message', reply, false)
  275. this.childOrigin = e.origin
  276. if (process.env.NODE_ENV !== 'production') {
  277. log('Parent: Saving Child origin', this.childOrigin)
  278. }
  279. return resolve(new ParentAPI(this))
  280. }
  281. // Might need to remove since parent might be receiving different messages
  282. // from different hosts
  283. if (process.env.NODE_ENV !== 'production') {
  284. log('Parent: Invalid handshake reply')
  285. }
  286. return reject('Failed handshake')
  287. }
  288. this.parent.addEventListener('message', reply, false)
  289. const doSend = () => {
  290. attempt++
  291. if (process.env.NODE_ENV !== 'production') {
  292. log(`Parent: Sending handshake attempt ${attempt}`, { childOrigin })
  293. }
  294. this.child.postMessage({
  295. postmate: 'handshake',
  296. type: messageType,
  297. model: this.model,
  298. }, childOrigin)
  299. if (attempt === maxHandshakeRequests) {
  300. clearInterval(responseInterval)
  301. }
  302. }
  303. const loaded = () => {
  304. doSend()
  305. responseInterval = setInterval(doSend, 500)
  306. }
  307. this.frame.addEventListener('load', loaded)
  308. if (process.env.NODE_ENV !== 'production') {
  309. log('Parent: Loading frame', { url })
  310. }
  311. this.frame.src = url
  312. })
  313. }
  314. }
  315. /**
  316. * The entry point of the Child
  317. */
  318. export class Model {
  319. public child: Window
  320. public model: any
  321. public parent: Window
  322. public parentOrigin: string
  323. /**
  324. * Initializes the child, model, parent, and responds to the Parents handshake
  325. * @param {Object} model Hash of values, functions, or promises
  326. * @return {Promise} The Promise that resolves when the handshake has been received
  327. */
  328. constructor (model) {
  329. this.child = window
  330. this.model = model
  331. this.parent = this.child.parent
  332. }
  333. /**
  334. * Responds to a handshake initiated by the Parent
  335. * @return {Promise} Resolves an object that exposes an API for the Child
  336. */
  337. sendHandshakeReply () {
  338. return new Promise((resolve, reject) => {
  339. const shake = (e) => {
  340. if (!e.data.postmate) {
  341. return
  342. }
  343. if (e.data.postmate === 'handshake') {
  344. if (process.env.NODE_ENV !== 'production') {
  345. log('Child: Received handshake from Parent')
  346. }
  347. this.child.removeEventListener('message', shake, false)
  348. if (process.env.NODE_ENV !== 'production') {
  349. log('Child: Sending handshake reply to Parent')
  350. }
  351. e.source.postMessage({
  352. postmate: 'handshake-reply',
  353. type: messageType,
  354. }, e.origin)
  355. this.parentOrigin = e.origin
  356. // Extend model with the one provided by the parent
  357. const defaults = e.data.model
  358. if (defaults) {
  359. Object.keys(defaults).forEach(key => {
  360. this.model[key] = defaults[key]
  361. })
  362. if (process.env.NODE_ENV !== 'production') {
  363. log('Child: Inherited and extended model from Parent')
  364. }
  365. }
  366. if (process.env.NODE_ENV !== 'production') {
  367. log('Child: Saving Parent origin', this.parentOrigin)
  368. }
  369. return resolve(new ChildAPI(this))
  370. }
  371. return reject('Handshake Reply Failed')
  372. }
  373. this.child.addEventListener('message', shake, false)
  374. })
  375. }
  376. }