|
| 1 | +import createManipulablePromise, { |
| 2 | + ManipulablePromise |
| 3 | +} from "./utils/manipulable-promise"; |
| 4 | +import { |
| 5 | + IncomingMessage, |
| 6 | + OutgoingMessage, |
| 7 | + JSONifiedMessage, |
| 8 | + Stringifyable |
| 9 | +} from "./message"; |
| 10 | +import { TALKER_TYPE, TALKER_ERR_TIMEOUT } from "./strings"; |
| 11 | + |
| 12 | +interface SentMessages { |
| 13 | + [id: number]: ManipulablePromise<IncomingMessage | Error>; |
| 14 | +} |
| 15 | + |
| 16 | +/** |
| 17 | + * Talker |
| 18 | + * Opens a communication line between this window and a remote window via postMessage. |
| 19 | + */ |
| 20 | +class Talker { |
| 21 | + /* |
| 22 | + * @property timeout - The number of milliseconds to wait before assuming no response will be received. |
| 23 | + */ |
| 24 | + public timeout: number = 3000; |
| 25 | + |
| 26 | + /** |
| 27 | + * @property onMessage - Will be called with every non-handshake, non-response message from the remote window |
| 28 | + */ |
| 29 | + onMessage?: (message: IncomingMessage) => void; |
| 30 | + |
| 31 | + // Will be resolved when a handshake is newly established with the remote window. |
| 32 | + private readonly handshake: ManipulablePromise<boolean>; |
| 33 | + // Whether we've received a handshake from the remote window |
| 34 | + private handshaken: boolean; |
| 35 | + // The ID of the latest OutgoingMessage |
| 36 | + private latestId: number = 0; |
| 37 | + private readonly queue: OutgoingMessage[] = []; |
| 38 | + private readonly sent: SentMessages = {}; |
| 39 | + |
| 40 | + /** |
| 41 | + * @param remoteWindow - The remote `window` object to post/receive messages to/from |
| 42 | + * @param remoteOrigin - The protocol, host, and port you expect the remoteWindow to be |
| 43 | + * @param localWindow - The local `window` object |
| 44 | + */ |
| 45 | + constructor( |
| 46 | + private readonly remoteWindow: Window, |
| 47 | + private readonly remoteOrigin: string, |
| 48 | + private readonly localWindow: Window = window |
| 49 | + ) { |
| 50 | + this.handshaken = false; |
| 51 | + this.handshake = createManipulablePromise(); |
| 52 | + |
| 53 | + this.localWindow.addEventListener( |
| 54 | + "message", |
| 55 | + (messageEvent: MessageEvent) => this.receiveMessage(messageEvent), |
| 56 | + false |
| 57 | + ); |
| 58 | + this.sendHandshake(); |
| 59 | + |
| 60 | + return this; |
| 61 | + } |
| 62 | + |
| 63 | + /** |
| 64 | + * @param namespace - The namespace the message is in |
| 65 | + * @param data - The data to send |
| 66 | + * @param responseToId - If this is a response to a previous message, its ID. |
| 67 | + */ |
| 68 | + send( |
| 69 | + namespace: string, |
| 70 | + data: Stringifyable, |
| 71 | + responseToId: number | null = null |
| 72 | + ): ManipulablePromise<IncomingMessage | Error> { |
| 73 | + const message: OutgoingMessage = new OutgoingMessage( |
| 74 | + this, |
| 75 | + namespace, |
| 76 | + data, |
| 77 | + responseToId |
| 78 | + ); |
| 79 | + |
| 80 | + const promise = createManipulablePromise<IncomingMessage | Error>(); |
| 81 | + |
| 82 | + this.sent[message.id] = promise; |
| 83 | + this.queue.push(message); |
| 84 | + this.flushQueue(); |
| 85 | + |
| 86 | + setTimeout( |
| 87 | + () => |
| 88 | + promise.__reject__ && promise.__reject__(new Error(TALKER_ERR_TIMEOUT)), |
| 89 | + this.timeout |
| 90 | + ); |
| 91 | + |
| 92 | + return promise; |
| 93 | + } |
| 94 | + |
| 95 | + /** |
| 96 | + * This is not marked private because other Talker-related classes need access to it, |
| 97 | + * but your application code should probably avoid calling this method. |
| 98 | + */ |
| 99 | + nextId(): number { |
| 100 | + return (this.latestId += 1); |
| 101 | + } |
| 102 | + |
| 103 | + private receiveMessage(messageEvent: MessageEvent): void { |
| 104 | + let object: JSONifiedMessage; |
| 105 | + try { |
| 106 | + object = JSON.parse(messageEvent.data); |
| 107 | + } catch (err) { |
| 108 | + object = { |
| 109 | + namespace: "", |
| 110 | + data: {}, |
| 111 | + id: this.nextId(), |
| 112 | + type: TALKER_TYPE |
| 113 | + }; |
| 114 | + } |
| 115 | + if ( |
| 116 | + !this.isSafeMessage(messageEvent.source, messageEvent.origin, object.type) |
| 117 | + ) { |
| 118 | + return; |
| 119 | + } |
| 120 | + |
| 121 | + const isHandshake = object.handshake || object.handshakeConfirmation; |
| 122 | + return isHandshake |
| 123 | + ? this.handleHandshake(object) |
| 124 | + : this.handleMessage(object); |
| 125 | + } |
| 126 | + |
| 127 | + /** |
| 128 | + * Determines whether it is safe and appropriate to parse a postMessage messageEvent |
| 129 | + * @param source - Source window object |
| 130 | + * @param origin - Protocol, host, and port |
| 131 | + * @param type - Internet Media Type |
| 132 | + */ |
| 133 | + private isSafeMessage( |
| 134 | + source: Window | MessagePort | ServiceWorker | null, |
| 135 | + origin: string, |
| 136 | + type: string |
| 137 | + ): boolean { |
| 138 | + const isSourceSafe = source === this.remoteWindow; |
| 139 | + const isOriginSafe = |
| 140 | + this.remoteOrigin === "*" || origin === this.remoteOrigin; |
| 141 | + const isTypeSafe = type === TALKER_TYPE; |
| 142 | + return isSourceSafe && isOriginSafe && isTypeSafe; |
| 143 | + } |
| 144 | + |
| 145 | + private handleHandshake(object: JSONifiedMessage): void { |
| 146 | + if (object.handshake) { |
| 147 | + // One last handshake in case the remote window (which we now know is ready) hasn't seen ours yet |
| 148 | + this.sendHandshake(this.handshaken); |
| 149 | + } |
| 150 | + if (!this.handshaken) { |
| 151 | + this.handshaken = true; |
| 152 | + if (this.handshake.__resolve__) { |
| 153 | + this.handshake.__resolve__(this.handshaken); |
| 154 | + } |
| 155 | + this.flushQueue(); |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + private handleMessage(rawObject: JSONifiedMessage): void { |
| 160 | + const message = new IncomingMessage( |
| 161 | + this, |
| 162 | + rawObject.namespace, |
| 163 | + rawObject.data, |
| 164 | + rawObject.id |
| 165 | + ); |
| 166 | + const responseId = rawObject.responseToId; |
| 167 | + return responseId |
| 168 | + ? this.respondToMessage(responseId, message) |
| 169 | + : this.broadcastMessage(message); |
| 170 | + } |
| 171 | + |
| 172 | + /** |
| 173 | + * @param id - Message ID of the waiting promise |
| 174 | + * @param message - Message that is responding to that ID |
| 175 | + */ |
| 176 | + private respondToMessage(id: number, message: IncomingMessage): void { |
| 177 | + const sent = this.sent[id]; |
| 178 | + if (sent && sent.__resolve__) { |
| 179 | + sent.__resolve__(message); |
| 180 | + delete this.sent[id]; |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + /** |
| 185 | + * Send a non-response message to awaiting hooks/callbacks |
| 186 | + * @param message Message that arrived |
| 187 | + */ |
| 188 | + private broadcastMessage(message: IncomingMessage): void { |
| 189 | + if (this.onMessage) { |
| 190 | + this.onMessage.call(this, message); |
| 191 | + } |
| 192 | + } |
| 193 | + |
| 194 | + /** |
| 195 | + * Send a handshake message to the remote window |
| 196 | + * @param confirmation - Is this a confirmation handshake? |
| 197 | + */ |
| 198 | + private sendHandshake(confirmation: boolean = false): void { |
| 199 | + return this.postMessage({ |
| 200 | + type: TALKER_TYPE, |
| 201 | + [confirmation ? "handshakeConfirmation" : "handshake"]: true |
| 202 | + }); |
| 203 | + } |
| 204 | + |
| 205 | + /** |
| 206 | + * Wrapper around window.postMessage to only send if we have the necessary objects |
| 207 | + */ |
| 208 | + private postMessage(data: OutgoingMessage | JSONifiedMessage): void { |
| 209 | + const message = JSON.stringify(data); |
| 210 | + if (this.remoteWindow && this.remoteOrigin) { |
| 211 | + try { |
| 212 | + this.remoteWindow.postMessage(message, this.remoteOrigin); |
| 213 | + } catch (e) { |
| 214 | + // no-op |
| 215 | + } |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + /** |
| 220 | + * Flushes the internal queue of outgoing messages, sending each one. |
| 221 | + * Does nothing if Talker has not handshaken with the remote. |
| 222 | + */ |
| 223 | + private flushQueue(): void { |
| 224 | + if (this.handshaken) { |
| 225 | + while (this.queue.length > 0) { |
| 226 | + const message = this.queue.shift(); |
| 227 | + if (message) { |
| 228 | + this.postMessage(message); |
| 229 | + } |
| 230 | + } |
| 231 | + } |
| 232 | + } |
| 233 | +} |
| 234 | + |
| 235 | +export { IncomingMessage, OutgoingMessage }; |
| 236 | +export default Talker; |
0 commit comments