├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── SECURITY.md ├── package-lock.json ├── package.json ├── readme.md ├── src ├── error.ts ├── index.ts ├── marbles.test.ts ├── reorder.ts ├── rpc.test.ts ├── rpc.ts └── types.ts ├── test ├── karma.conf.js ├── karma.shim.js └── webpack.config.js ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | end_of_line = lf 8 | indent_style = space 9 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### joe made this: http://goel.io/joe 2 | #### node #### 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (http://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directories 33 | node_modules 34 | jspm_packages 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional eslint cache 40 | .eslintcache 41 | 42 | # Optional REPL history 43 | .node_repl_history 44 | /dist 45 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/dist 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | language: node_js 4 | addons: 5 | chrome: stable 6 | before_script: 7 | - "sudo chown root /opt/google/chrome/chrome-sandbox" 8 | - "sudo chmod 4755 /opt/google/chrome/chrome-sandbox" 9 | node_js: 10 | - 8 11 | git: 12 | depth: 10 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mixer/postmessage-rpc", 3 | "version": "1.1.4", 4 | "description": "Remote procedure call layer between browser contexts", 5 | "main": "dist/rpc.js", 6 | "typings": "dist/rpc.d.ts", 7 | "scripts": { 8 | "test": "npm-run-all --parallel --silent test:lint test:unit", 9 | "test:unit": "karma start test/karma.conf.js --single-run", 10 | "test:lint": "tslint -t verbose --project tsconfig.json \"src/**/*.ts\"", 11 | "test:watch": "karma start test/karma.conf.js --no-single-run", 12 | "fmt": "prettier --write \"src/**/*.{ts,js}\" && npm run -s test:lint -- --fix", 13 | "prepare": "tsc" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/mixer/postmessage-rpc.git" 18 | }, 19 | "keywords": [ 20 | "rpc", 21 | "postmessage" 22 | ], 23 | "author": "Connor Peet ", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/mixer/postmessage-rpc/issues" 27 | }, 28 | "homepage": "https://github.com/mixer/postmessage-rpc#readme", 29 | "devDependencies": { 30 | "@types/chai": "^4.1.4", 31 | "@types/chai-subset": "^1.3.1", 32 | "@types/mocha": "^5.2.5", 33 | "@types/node": "^10.9.4", 34 | "@types/sinon": "^5.0.2", 35 | "chai": "^4.1.2", 36 | "chai-subset": "^1.6.0", 37 | "istanbul-instrumenter-loader": "^3.0.1", 38 | "karma": "^3.0.0", 39 | "karma-browserstack-launcher": "^1.3.0", 40 | "karma-chrome-launcher": "^2.2.0", 41 | "karma-coverage": "^1.1.2", 42 | "karma-coverage-istanbul-reporter": "^2.0.2", 43 | "karma-mocha": "^1.3.0", 44 | "karma-mocha-reporter": "^2.2.5", 45 | "karma-webpack": "^3.0.0", 46 | "mocha": "^5.2.0", 47 | "npm-run-all": "^4.1.3", 48 | "prettier": "^1.14.2", 49 | "sinon": "^6.1.5", 50 | "ts-loader": "^4.5.0", 51 | "tslint": "^5.11.0", 52 | "tslint-config-prettier": "^1.15.0", 53 | "typescript": "~2.8.2", 54 | "webpack": "^4.17.1" 55 | }, 56 | "dependencies": { 57 | "eventemitter3": "^3.1.0" 58 | }, 59 | "prettier": { 60 | "trailingComma": "all", 61 | "singleQuote": true, 62 | "printWidth": 100, 63 | "tabWidth": 2 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # @mixer/postmessage-rpc 2 | 3 | This is a library for making RPC calls (asynchronous method calls) between browser windows or iframes. It builds upon the browser `postMessage` API, which lacks some features that complex applications may depend upon: 4 | 5 | - The ability to have transactional request/response calls between windows 6 | - Easy scoping between multiple applications; `postMessage` events are global on the window, so multiple postMessage targets must be disambiguated. 7 | - `postMessage` does not guarantee ordering, which can lead to surprising and difficult to diagnose bugs. 8 | 9 | This RPC layer resolves those issues. 10 | 11 | ### Example Usage 12 | 13 | The RPC class is symmetrical and should be created on both windows you want to talk between. Say you want to embed `iframe.html` inside the `parent.html`, and have the parent provide a function that adds things the child gives it. That might look like: 14 | 15 | **parent.js** 16 | 17 | ```js 18 | import { RPC } from '@mixer/postmessage-rpc'; 19 | 20 | const rpc = new RPC({ 21 | // The window you want to talk to: 22 | target: myIframe.contentWindow, 23 | // This should be unique for each of your producer<->consumer pairs: 24 | serviceId: 'my-awesome-service', 25 | 26 | // Optionally, allowlist the origin you want to talk to: 27 | // origin: 'example.com', 28 | }); 29 | 30 | rpc.expose('add', (data) => data.a + data.b); 31 | ``` 32 | 33 | **iframe.js** 34 | 35 | ```js 36 | import { RPC } from '@mixer/postmessage-rpc'; 37 | 38 | const rpc = new RPC({ 39 | target: window.parent, 40 | serviceId: 'my-awesome-service', 41 | }); 42 | 43 | rpc.call('add', { a: 3, b: 5 }).then(result => console.log('3 + 5 is', result)); 44 | ``` 45 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An RPCError can be thrown in socket.call() if bad input is 3 | * passed to the service. 4 | */ 5 | export class RPCError extends Error { 6 | constructor( 7 | public readonly code: number, 8 | public readonly message: string, 9 | public readonly path?: string[], 10 | ) { 11 | super(`Error #${code}: ${message}`); 12 | 13 | // Patch for ES5 compilation target errors: 14 | Object.setPrototypeOf(this, RPCError.prototype); 15 | } 16 | 17 | public toReplyError() { 18 | return { 19 | code: this.code, 20 | message: this.message, 21 | path: this.path, 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error'; 2 | export * from './rpc'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /src/marbles.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai'; 2 | import { RPC } from './rpc'; 3 | import { IMessageEvent, RPCMessage } from './types'; 4 | 5 | // tslint:disable-next-line 6 | use(require('chai-subset')); 7 | 8 | /** 9 | * Function that returns a delay of the requested number of milliseconds. 10 | */ 11 | export const delay = (duration: number = 1) => 12 | new Promise(resolve => setTimeout(resolve, duration)); 13 | 14 | /** 15 | * Action defines the assertion to run in the test definition 16 | */ 17 | export const enum Action { 18 | Send, 19 | Handler, 20 | Receive, 21 | IsReady, 22 | } 23 | 24 | /** 25 | * Definition is passed to the IMarbleTest to define what checks to run 26 | * at each point. In order: 27 | * 28 | * 1. Send a method. From the RPC, it should have a method name and body. 29 | * 2. Asserts that the RPC is in the ready state at the given time. 30 | * 3. Asserts that we receive something that matches the given subset 31 | * 4. Asserts that we receive something that deep equals the given object 32 | * 5. Asserts that we receive something, and passes the message to the test 33 | * function. 34 | */ 35 | export type Definition = 36 | | { action: Action.Send; method?: string; data: any } 37 | | { action: Action.IsReady; value: boolean } 38 | | { action: Action.Receive; subset: any } 39 | | { action: Action.Receive; object: any } 40 | | { action: Action.Receive; tester: (r: RPCMessage) => void }; 41 | 42 | function testMethod(charDef: string, def: Definition, message: RPCMessage | undefined) { 43 | if (def.action !== Action.Receive) { 44 | return; 45 | } 46 | 47 | if (!message) { 48 | throw new Error(`Expected to get reply for definition ${charDef}, but we didn't`); 49 | } 50 | 51 | if ('subset' in def) { 52 | expect(message).to.containSubset(def.subset); 53 | } else if ('object' in def) { 54 | expect(message).to.deep.equal(def.object, `expected objects in def ${charDef} to be equal`); 55 | } else { 56 | def.tester(message); 57 | } 58 | } 59 | 60 | /** 61 | * Options passed into testMarbles. 62 | */ 63 | export interface IMarbleTest { 64 | /** 65 | * Marble test for RPC instance. E.g. `--a-b-c`. Dashes are "no ops", 66 | * letters match up to assertions in the `definitions` object. 67 | */ 68 | rpcInstance: string; 69 | 70 | /** 71 | * Marble test for "remote window". E.g. `--a-b-c`. Dashes are "no ops", 72 | * letters match up to assertions in the `definitions` object. 73 | */ 74 | remoteContx: string; 75 | 76 | /** 77 | * Assertions matching up to the marble test. 78 | */ 79 | definitions: { [char: string]: Definition }; 80 | 81 | /** 82 | * Handler methods to attach to the marbles. 83 | */ 84 | handlers?: { [method: string]: (data: any) => any }; 85 | } 86 | 87 | /** 88 | * rxjs-style marble sequence tests. 89 | */ 90 | export async function testMarbles({ 91 | rpcInstance, 92 | remoteContx, 93 | definitions, 94 | handlers, 95 | }: IMarbleTest) { 96 | const messagesSentToRemote: Array> = []; 97 | const messagesReceivedByRPC: Array> = []; 98 | 99 | let sendMessage: null | ((ev: IMessageEvent) => void) = null; 100 | let toreDown = false; 101 | let isReady = false; 102 | 103 | const rpc = new RPC({ 104 | target: { postMessage: (data: any) => messagesSentToRemote.push(JSON.parse(data)) }, 105 | receiver: { 106 | readMessages: callback => { 107 | sendMessage = event => callback({ data: JSON.stringify(event.data), origin: event.origin }); 108 | return () => (toreDown = true); 109 | }, 110 | }, 111 | origin: 'example.com', 112 | serviceId: 'foo', 113 | }); 114 | 115 | rpc.on('recvData', p => messagesReceivedByRPC.push(p)); 116 | rpc.on('isReady', () => (isReady = true)); 117 | 118 | while (rpcInstance.length < remoteContx.length) { 119 | rpcInstance += '-'; 120 | } 121 | while (remoteContx.length < rpcInstance.length) { 122 | remoteContx += '-'; 123 | } 124 | 125 | if (handlers) { 126 | for (const key of Object.keys(handlers)) { 127 | rpc.expose(key, handlers[key]); 128 | } 129 | } 130 | 131 | for (let i = 0; i < rpcInstance.length; i++) { 132 | if (rpcInstance[i] !== '-') { 133 | const def = definitions[rpcInstance[i]]; 134 | if (!def) { 135 | throw new Error(`Unknown action ${rpcInstance[i]}`); 136 | } 137 | 138 | if (def.action === Action.Send) { 139 | rpc.call(def.method!, def.data, false); 140 | } else if (def.action === Action.Receive) { 141 | testMethod(rpcInstance[i], def, messagesReceivedByRPC.shift()); 142 | } else if (def.action === Action.IsReady) { 143 | expect(isReady).to.equal( 144 | def.value, 145 | `expected the rpc.ready=${def.value} by ${rpcInstance[i]}, but it was not`, 146 | ); 147 | } 148 | } 149 | 150 | if (remoteContx[i] !== '-') { 151 | const def = definitions[remoteContx[i]]; 152 | if (!def) { 153 | throw new Error(`Unknown action ${remoteContx[i]}`); 154 | } 155 | 156 | if (def.action === Action.Send) { 157 | sendMessage!(def.data); 158 | } else { 159 | testMethod(rpcInstance[i], def, messagesSentToRemote.shift()); 160 | } 161 | } 162 | 163 | await delay(); 164 | } 165 | 166 | rpc.destroy(); 167 | expect(toreDown).to.be.true; 168 | } 169 | -------------------------------------------------------------------------------- /src/reorder.ts: -------------------------------------------------------------------------------- 1 | import { RPCMessageWithCounter } from './types'; 2 | 3 | /** 4 | * Reorder is a utility responsible for reording incoming messages. 5 | */ 6 | export class Reorder { 7 | /** 8 | * Last call we got which was in sequence.. 9 | */ 10 | private lastSequentialCall = -1; 11 | 12 | /** 13 | * Queue of messages to send out once reordered data comes back. 14 | */ 15 | private queue: Array> = []; 16 | 17 | /** 18 | * Resets the queue and call counter to the given value. 19 | */ 20 | public reset(counter: number) { 21 | this.lastSequentialCall = counter - 1; 22 | this.queue = []; 23 | } 24 | 25 | /** 26 | * Appends a message to the reorder queue. Returns all messages which 27 | * are good to send out. 28 | */ 29 | public append(packet: RPCMessageWithCounter): Array> { 30 | if (packet.counter <= this.lastSequentialCall + 1) { 31 | const list = [packet]; 32 | this.lastSequentialCall = packet.counter; 33 | this.replayQueue(list); 34 | return list; 35 | } 36 | 37 | for (let i = 0; i < this.queue.length; i++) { 38 | if (this.queue[i].counter > packet.counter) { 39 | this.queue.splice(i, 0, packet); 40 | return []; 41 | } 42 | } 43 | 44 | this.queue.push(packet); 45 | return []; 46 | } 47 | 48 | private replayQueue(list: Array>) { 49 | while (this.queue.length) { 50 | const next = this.queue[0]; 51 | if (next.counter > this.lastSequentialCall + 1) { 52 | return; 53 | } 54 | 55 | list.push(this.queue.shift()!); 56 | this.lastSequentialCall = next.counter; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/rpc.test.ts: -------------------------------------------------------------------------------- 1 | // import { expect } from 'chai'; 2 | 3 | import { RPCError } from './error'; 4 | import { Action, Definition, testMarbles } from './marbles.test'; 5 | 6 | const makeReply = (id: number, counter: number, result: any) => ({ 7 | data: { 8 | type: 'reply', 9 | serviceID: 'foo', 10 | counter, 11 | id, 12 | result, 13 | }, 14 | origin: 'example.com', 15 | }); 16 | 17 | const makeCall = (id: number, counter: number, method: string, params: any, discard = false) => ({ 18 | data: { 19 | type: 'method', 20 | serviceID: 'foo', 21 | discard, 22 | method, 23 | counter, 24 | id, 25 | params, 26 | }, 27 | origin: 'example.com', 28 | }); 29 | 30 | const expectReady: Definition = { 31 | action: Action.Receive, 32 | object: { 33 | type: 'method', 34 | method: 'ready', 35 | discard: false, 36 | id: -1, 37 | serviceID: 'foo', 38 | counter: 0, 39 | params: { 40 | protocolVersion: '1.0', 41 | }, 42 | }, 43 | }; 44 | 45 | describe('RPC', () => { 46 | it('says it is ready if it gets a ready reply', () => 47 | testMarbles({ 48 | rpcInstance: 'a--de', 49 | remoteContx: '-bc--', 50 | definitions: { 51 | a: { action: Action.IsReady, value: false }, 52 | b: expectReady, 53 | c: { 54 | action: Action.Send, 55 | data: makeReply(-1, 0, { 56 | protocolVersion: '1.0', 57 | }), 58 | }, 59 | d: { 60 | action: Action.Receive, 61 | subset: { 62 | type: 'reply', 63 | result: { 64 | protocolVersion: '1.0', 65 | }, 66 | }, 67 | }, 68 | e: { action: Action.IsReady, value: true }, 69 | }, 70 | })); 71 | 72 | it('says it is ready if it gets a ready event later', () => 73 | testMarbles({ 74 | rpcInstance: 'a--de', 75 | remoteContx: '-bc--', 76 | definitions: { 77 | a: { action: Action.IsReady, value: false }, 78 | b: expectReady, 79 | c: { 80 | action: Action.Send, 81 | data: makeCall( 82 | 0, 83 | 0, 84 | 'ready', 85 | { 86 | protocolVersion: '1.0', 87 | }, 88 | true, 89 | ), 90 | }, 91 | d: { 92 | action: Action.Receive, 93 | subset: { 94 | type: 'method', 95 | method: 'ready', 96 | params: { 97 | protocolVersion: '1.0', 98 | }, 99 | }, 100 | }, 101 | e: { action: Action.IsReady, value: true }, 102 | }, 103 | })); 104 | 105 | it('does not break on an empty/errorful ready reply (back compat)', () => 106 | testMarbles({ 107 | rpcInstance: 'a--de', 108 | remoteContx: '-bc--', 109 | definitions: { 110 | a: { action: Action.IsReady, value: false }, 111 | b: expectReady, 112 | c: { 113 | action: Action.Send, 114 | data: makeReply(-1, 0, null), 115 | }, 116 | d: { 117 | action: Action.Receive, 118 | subset: { 119 | type: 'reply', 120 | id: -1, 121 | result: null, 122 | }, 123 | }, 124 | e: { action: Action.IsReady, value: true }, 125 | }, 126 | })); 127 | 128 | it('should reject methods from invalid service IDs', () => 129 | testMarbles({ 130 | rpcInstance: '---', 131 | remoteContx: 'ab-', 132 | definitions: { 133 | a: expectReady, 134 | b: { 135 | action: Action.Send, 136 | data: { 137 | data: { 138 | type: 'method', 139 | method: 'ready', 140 | serviceID: 'wut', 141 | counter: 0, 142 | }, 143 | origin: 'example.com', 144 | }, 145 | }, 146 | }, 147 | })); 148 | 149 | it('should reject methods from invalid origins', () => 150 | testMarbles({ 151 | rpcInstance: '---', 152 | remoteContx: 'ab-', 153 | definitions: { 154 | a: expectReady, 155 | b: { 156 | action: Action.Send, 157 | data: { 158 | data: { 159 | type: 'method', 160 | method: 'ready', 161 | serviceID: 'foo', 162 | counter: 0, 163 | }, 164 | origin: 'wut.com', 165 | }, 166 | }, 167 | }, 168 | })); 169 | 170 | it('should reject malformed messages', () => 171 | testMarbles({ 172 | rpcInstance: '---', 173 | remoteContx: 'ab-', 174 | definitions: { 175 | a: expectReady, 176 | b: { 177 | action: Action.Send, 178 | data: { 179 | data: { 180 | potato: true, 181 | }, 182 | origin: 'example.com', 183 | }, 184 | }, 185 | }, 186 | })); 187 | 188 | it('should make calls and receive+reorder replies', () => 189 | testMarbles({ 190 | rpcInstance: '-bcd---hij', 191 | remoteContx: 'a---efg--', 192 | definitions: { 193 | a: expectReady, 194 | b: { 195 | action: Action.Send, 196 | method: 'firstMethod', 197 | data: {}, 198 | }, 199 | c: { 200 | action: Action.Send, 201 | method: 'secondMethod', 202 | data: {}, 203 | }, 204 | d: { 205 | action: Action.Send, 206 | method: 'thirdMethod', 207 | data: {}, 208 | }, 209 | e: { 210 | action: Action.Send, 211 | data: makeReply(3, 2, 'thirdReply'), 212 | }, 213 | f: { 214 | action: Action.Send, 215 | data: makeReply(2, 1, 'secondReply'), 216 | }, 217 | g: { 218 | action: Action.Send, 219 | data: makeReply(1, 0, 'firstReply'), 220 | }, 221 | h: { 222 | action: Action.Receive, 223 | subset: { result: 'firstReply' }, 224 | }, 225 | i: { 226 | action: Action.Receive, 227 | subset: { result: 'secondReply' }, 228 | }, 229 | j: { 230 | action: Action.Receive, 231 | subset: { result: 'thirdReply' }, 232 | }, 233 | }, 234 | })); 235 | 236 | it('should bubble any returned rpc errors', () => 237 | testMarbles({ 238 | rpcInstance: '--c-', 239 | remoteContx: 'ab-d', 240 | handlers: { 241 | bar: () => { 242 | throw new RPCError(1234, 'oh no!'); 243 | }, 244 | }, 245 | definitions: { 246 | a: expectReady, 247 | b: { action: Action.Send, data: makeCall(0, 0, 'bar', null) }, 248 | c: { 249 | action: Action.Receive, 250 | subset: { method: 'bar' }, 251 | }, 252 | d: { 253 | action: Action.Receive, 254 | subset: { 255 | error: { code: 1234, message: 'oh no!' }, 256 | }, 257 | }, 258 | }, 259 | })); 260 | 261 | it('should error on unknown methods', () => 262 | testMarbles({ 263 | rpcInstance: '--c-', 264 | remoteContx: 'ab-d', 265 | definitions: { 266 | a: expectReady, 267 | b: { action: Action.Send, data: makeCall(0, 0, 'bar', null) }, 268 | c: { 269 | action: Action.Receive, 270 | subset: { method: 'bar' }, 271 | }, 272 | d: { 273 | action: Action.Receive, 274 | subset: { 275 | error: { code: 4003, message: 'Unknown method name "bar"' }, 276 | }, 277 | }, 278 | }, 279 | })); 280 | 281 | it('should reset call counter when ready is received', () => 282 | testMarbles({ 283 | rpcInstance: '-b--ef--', 284 | remoteContx: 'a-cd--gh', 285 | definitions: { 286 | a: expectReady, 287 | b: { 288 | action: Action.Send, 289 | method: 'hello', 290 | data: true, 291 | }, 292 | c: { 293 | action: Action.Receive, 294 | subset: { method: 'hello', counter: 1 }, 295 | }, 296 | d: { 297 | action: Action.Send, 298 | data: makeCall(-1, 0, 'ready', { 299 | protocolVersion: '1.0', 300 | }), 301 | }, 302 | e: expectReady, 303 | f: { 304 | action: Action.Send, 305 | method: 'helloAgain', 306 | data: true, 307 | }, 308 | g: { 309 | action: Action.Receive, 310 | subset: { type: 'reply', id: -1, counter: 0 }, 311 | }, 312 | h: { 313 | action: Action.Receive, 314 | subset: { type: 'method', counter: 1 }, 315 | }, 316 | }, 317 | })); 318 | }); 319 | -------------------------------------------------------------------------------- /src/rpc.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'eventemitter3'; 2 | 3 | import { RPCError } from './error'; 4 | import { Reorder } from './reorder'; 5 | import { 6 | defaultRecievable, 7 | IMessageEvent, 8 | IPostable, 9 | IReceivable, 10 | IRPCMethod, 11 | IRPCReply, 12 | isRPCMessage, 13 | RPCMessage, 14 | RPCMessageWithCounter, 15 | } from './types'; 16 | 17 | function objToError(obj: { code: number; message: string; path?: string[] }) { 18 | return new RPCError(obj.code, obj.message, obj.path); 19 | } 20 | 21 | /** 22 | * IRPCOptions are used to construct an RPc instance. 23 | */ 24 | export interface IRPCOptions { 25 | /** 26 | * Target window to send messages to, like an iframe. 27 | */ 28 | target: IPostable; 29 | 30 | /** 31 | * Unique string that identifies this RPC service. This is used so that 32 | * multiple RPC instances can communicate on the page without interference. 33 | * This should be the same on both the sending and receiving end. 34 | */ 35 | serviceId: string; 36 | 37 | /** 38 | * Remote origin that we'll communicate with. It may be set to and 39 | * defaults to '*'. 40 | */ 41 | origin?: string; 42 | 43 | /** 44 | * Protocol version that socket will advertise. Defaults to 1.0. You can 45 | * rev this for compatibility changes between consumers. 46 | */ 47 | protocolVersion?: string; 48 | 49 | /** 50 | * Window to read messages from. Defaults to the current window. 51 | */ 52 | receiver?: IReceivable; 53 | } 54 | 55 | /** 56 | * Magic ID used for the "ready" call. 57 | */ 58 | const magicReadyCallId = -1; 59 | 60 | /** 61 | * Primitive postMessage based RPC. 62 | */ 63 | export class RPC extends EventEmitter { 64 | /** 65 | * Promise that resolves once the RPC connection is established. 66 | */ 67 | public readonly isReady: Promise; 68 | /** 69 | * A map of IDs to callbacks we'll fire whenever the remote frame responds. 70 | */ 71 | private calls: { 72 | [id: number]: (err: null | RPCError, result: any) => void; 73 | } = Object.create(null); 74 | 75 | /** 76 | * Counter to track the sequence number of our calls for reordering. 77 | * Incremented each time we send a message. 78 | */ 79 | private callCounter = 0; 80 | /** 81 | * Reorder utility for incoming messages. 82 | */ 83 | private reorder = new Reorder(); 84 | /** 85 | * Protocol version the remote frame advertised. 86 | */ 87 | private remoteProtocolVersion: string | undefined; 88 | 89 | /** 90 | * Callback invoked when we destroy this RPC instance. 91 | */ 92 | private unsubscribeCallback: () => void; 93 | 94 | /** 95 | * Creates a new RPC instance. Note: you should use the `rpc` singleton, 96 | * rather than creating this class directly, in your controls. 97 | */ 98 | constructor(private readonly options: IRPCOptions) { 99 | super(); 100 | this.unsubscribeCallback = (options.receiver || defaultRecievable).readMessages(this.listener); 101 | 102 | // Both sides will fire "ready" when they're set up. When either we get 103 | // a ready or the other side successfully responds that they're ready, 104 | // resolve the "ready" promise. 105 | this.isReady = new Promise(resolve => { 106 | const response = { protocolVersion: options.protocolVersion || '1.0' }; 107 | 108 | this.expose('ready', () => { 109 | resolve(); 110 | return response; 111 | }); 112 | 113 | this.call('ready', response) 114 | .then(resolve) 115 | .catch(resolve); 116 | }); 117 | } 118 | 119 | /** 120 | * Create instantiates a new RPC instance and waits until it's ready 121 | * before returning. 122 | */ 123 | public create(options: IRPCOptions): Promise { 124 | const rpc = new RPC(options); 125 | return rpc.isReady.then(() => rpc); 126 | } 127 | 128 | /** 129 | * Attaches a method callable by the other window, to this one. The handler 130 | * function will be invoked with whatever the other window gives us. Can 131 | * return a Promise, or the results directly. 132 | * 133 | * @param {string} method 134 | * @param {function(params: any): Promise.<*>|*} handler 135 | */ 136 | public expose(method: string, handler: (params: T) => Promise | any): this { 137 | this.on(method, (data: IRPCMethod) => { 138 | if (data.discard) { 139 | handler(data.params); 140 | return; 141 | } 142 | 143 | // tslint:disable-next-line 144 | new Promise(resolve => resolve(handler(data.params))) 145 | .then( 146 | result => 147 | ({ 148 | type: 'reply', 149 | serviceID: this.options.serviceId, 150 | id: data.id, 151 | result, 152 | } as IRPCReply), 153 | ) 154 | .catch( 155 | (err: Error) => 156 | ({ 157 | type: 'reply', 158 | serviceID: this.options.serviceId, 159 | id: data.id, 160 | error: 161 | err instanceof RPCError 162 | ? err.toReplyError() 163 | : { code: 0, message: err.stack || err.message }, 164 | } as IRPCReply), 165 | ) 166 | .then(packet => { 167 | this.emit('sendReply', packet); 168 | this.post(packet); 169 | }); 170 | }); 171 | 172 | return this; 173 | } 174 | 175 | public call(method: string, params: object, waitForReply?: true): Promise; 176 | public call(method: string, params: object, waitForReply: false): void; 177 | 178 | /** 179 | * Makes an RPC call out to the target window. 180 | * 181 | * @param {string} method 182 | * @param {*} params 183 | * @param {boolean} [waitForReply=true] 184 | * @return {Promise. | undefined} If waitForReply is true, a 185 | * promise is returned that resolves once the server responds. 186 | */ 187 | public call(method: string, params: object, waitForReply: boolean = true): Promise | void { 188 | const id = method === 'ready' ? magicReadyCallId : this.callCounter; 189 | const packet: IRPCMethod = { 190 | type: 'method', 191 | serviceID: this.options.serviceId, 192 | id, 193 | params, 194 | method, 195 | discard: !waitForReply, 196 | }; 197 | 198 | this.emit('sendMethod', packet); 199 | this.post(packet); 200 | 201 | if (!waitForReply) { 202 | return; 203 | } 204 | 205 | return new Promise((resolve, reject) => { 206 | this.calls[id] = (err, res) => { 207 | if (err) { 208 | reject(err); 209 | } else { 210 | resolve(res); 211 | } 212 | }; 213 | }); 214 | } 215 | 216 | /** 217 | * Tears down resources associated with the RPC client. 218 | */ 219 | public destroy() { 220 | this.emit('destroy'); 221 | this.unsubscribeCallback(); 222 | } 223 | 224 | /** 225 | * Returns the protocol version that the remote client implements. This 226 | * will return `undefined` until we get a `ready` event. 227 | * @return {string | undefined} 228 | */ 229 | public remoteVersion(): string | undefined { 230 | return this.remoteProtocolVersion; 231 | } 232 | 233 | private handleReply(packet: IRPCReply) { 234 | const handler = this.calls[packet.id]; 235 | if (!handler) { 236 | return; 237 | } 238 | 239 | if (packet.error) { 240 | handler(objToError(packet.error), null); 241 | } else { 242 | handler(null, packet.result); 243 | } 244 | 245 | delete this.calls[packet.id]; 246 | } 247 | 248 | private post(message: RPCMessage) { 249 | (message as RPCMessageWithCounter).counter = this.callCounter++; 250 | this.options.target.postMessage(JSON.stringify(message), this.options.origin || '*'); 251 | } 252 | 253 | private isReadySignal(packet: RPCMessageWithCounter) { 254 | if (packet.type === 'method' && packet.method === 'ready') { 255 | return true; 256 | } 257 | 258 | if (packet.type === 'reply' && packet.id === magicReadyCallId) { 259 | return true; 260 | } 261 | 262 | return false; 263 | } 264 | 265 | private listener = (ev: IMessageEvent) => { 266 | // If we got data that wasn't a string or could not be parsed, or was 267 | // from a different remote, it's not for us. 268 | if (this.options.origin && this.options.origin !== '*' && ev.origin !== this.options.origin) { 269 | return; 270 | } 271 | 272 | let packet: RPCMessageWithCounter; 273 | try { 274 | packet = JSON.parse(ev.data); 275 | } catch (e) { 276 | return; 277 | } 278 | 279 | if (!isRPCMessage(packet) || packet.serviceID !== this.options.serviceId) { 280 | return; 281 | } 282 | 283 | // postMessage does not guarantee message order, reorder messages as needed. 284 | // Reset the call counter when we get a "ready" so that the other end sees 285 | // calls starting from 0. 286 | 287 | if (this.isReadySignal(packet)) { 288 | const params: { protocolVersion: string } | undefined = 289 | packet.type === 'method' ? packet.params : packet.result; 290 | if (params && params.protocolVersion) { 291 | this.remoteProtocolVersion = params.protocolVersion; 292 | } else { 293 | this.remoteProtocolVersion = this.remoteProtocolVersion; 294 | } 295 | 296 | this.callCounter = 0; 297 | this.reorder.reset(packet.counter); 298 | this.emit('isReady', true); 299 | } 300 | 301 | for (const p of this.reorder.append(packet)) { 302 | this.emit('recvData', p); 303 | this.dispatchIncoming(p); 304 | } 305 | }; 306 | 307 | private dispatchIncoming(packet: RPCMessageWithCounter) { 308 | switch (packet.type) { 309 | case 'method': 310 | this.emit('recvMethod', packet); 311 | if (this.listeners(packet.method).length > 0) { 312 | this.emit(packet.method, packet); 313 | return; 314 | } 315 | 316 | this.post({ 317 | type: 'reply', 318 | serviceID: this.options.serviceId, 319 | id: packet.id, 320 | error: { code: 4003, message: `Unknown method name "${packet.method}"` }, 321 | result: null, 322 | }); 323 | break; 324 | case 'reply': 325 | this.emit('recvReply', packet); 326 | this.handleReply(packet); 327 | break; 328 | default: 329 | // Ignore 330 | } 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines any message sentable over RPC. 3 | */ 4 | export type RPCMessage = IRPCMethod | IRPCReply; 5 | 6 | /** 7 | * Defines an RPC message with a sequence counter for reordering. 8 | */ 9 | export type RPCMessageWithCounter = RPCMessage & { counter: number }; 10 | 11 | /** 12 | * Describes an RPC method call. 13 | */ 14 | export interface IRPCMethod { 15 | type: 'method'; 16 | serviceID: string; 17 | id: number; 18 | method: string; 19 | discard?: boolean; 20 | params: T; 21 | } 22 | 23 | /** 24 | * Describes an RPC method reply. 25 | */ 26 | export interface IRPCReply { 27 | type: 'reply'; 28 | serviceID: string; 29 | id: number; 30 | result: T; 31 | error?: { 32 | code: number; 33 | message: string; 34 | path?: string[]; 35 | }; 36 | } 37 | 38 | /** 39 | * Checks whether the message duck-types into an Interactive message. 40 | * This is needed to distinguish between postmessages that we get, 41 | * and postmessages from other sources. 42 | */ 43 | export function isRPCMessage(data: any): data is RPCMessageWithCounter { 44 | return (data.type === 'method' || data.type === 'reply') && typeof data.counter === 'number'; 45 | } 46 | 47 | /** 48 | * Describes a PostMessage event that the RPC will read. A subset of the 49 | * MessageEvent DOM type. 50 | */ 51 | export interface IMessageEvent { 52 | data: any; 53 | origin: string; 54 | } 55 | 56 | /** 57 | * IPostable is an interface that describes something to which we can send a 58 | * browser postMessage. It's implemented by the `window`, and is mocked 59 | * in tests. 60 | */ 61 | export interface IPostable { 62 | postMessage(data: any, targetOrigin: string): void; 63 | } 64 | 65 | /** 66 | * IRecievable is an interface that describes something from wheich we can 67 | * read a browser postMessage. It's implemented by the `window`, and is mocked 68 | * in tests. 69 | */ 70 | export interface IReceivable { 71 | /** 72 | * Takes a callback invoked to invoke whenever a message is received, 73 | * and returns a function the can be used to unsubscribe the callback. 74 | */ 75 | readMessages(callback: (ev: IMessageEvent) => void): () => void; 76 | } 77 | 78 | /** 79 | * Default `IRecievable` implementation that listens on the window. 80 | */ 81 | export const defaultRecievable: IReceivable = { 82 | readMessages(callback) { 83 | window.addEventListener('message', callback); 84 | return () => window.removeEventListener('message', callback); 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /test/karma.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = config => { 4 | const isBrowserstack = Boolean(process.env.USE_BROWSER_STACK); 5 | const launchers = { 6 | bsFirefox: { 7 | base: 'BrowserStack', 8 | browser: 'firefox', 9 | os: 'Windows', 10 | os_version: '10', 11 | }, 12 | bsSafari: { 13 | base: 'BrowserStack', 14 | browser: 'safari', 15 | os: 'OS X', 16 | os_version: 'High Sierra', 17 | }, 18 | bsEdge: { 19 | base: 'BrowserStack', 20 | browser: 'edge', 21 | browser_version: '14', 22 | os: 'Windows', 23 | os_version: '10', 24 | }, 25 | bsChrome: { 26 | base: 'BrowserStack', 27 | browser: 'chrome', 28 | os: 'Windows', 29 | os_version: '10', 30 | }, 31 | }; 32 | 33 | config.set({ 34 | /** 35 | * General base config: 36 | */ 37 | basePath: path.join(__dirname, '..'), 38 | frameworks: ['mocha'], 39 | reporters: isBrowserstack ? ['mocha', 'BrowserStack'] : ['mocha', 'coverage-istanbul'], 40 | browserStack: {}, 41 | coverageIstanbulReporter: { 42 | reports: ['text-summary', 'html'], 43 | // fixWebpackSourcePaths: true, 44 | dir: path.join(__dirname, '../coverage'), 45 | }, 46 | 47 | client: { 48 | mocha: { 49 | timeout: 10000, 50 | }, 51 | }, 52 | 53 | customLaunchers: launchers, 54 | 55 | plugins: [ 56 | require('karma-mocha'), 57 | require('karma-mocha-reporter'), 58 | require('karma-chrome-launcher'), 59 | require('karma-browserstack-launcher'), 60 | require('karma-webpack'), 61 | require('karma-coverage-istanbul-reporter'), 62 | ], 63 | 64 | /** 65 | * Webpack and bundling config: 66 | */ 67 | webpack: require('./webpack.config'), 68 | webpackServer: { noInfo: true }, 69 | webpackMiddleware: { stats: 'errors-only' }, 70 | files: ['test/karma.shim.js'], 71 | preprocessors: { 'test/karma.shim.js': ['webpack'] }, 72 | 73 | /** 74 | * Karma run config: 75 | */ 76 | browsers: isBrowserstack ? Object.keys(launchers) : ['ChromeHeadless'], 77 | port: 9876, 78 | colors: true, 79 | logLevel: config.LOG_INFO, 80 | singleRun: true, 81 | 82 | mochaReporter: { 83 | showDiff: true, 84 | }, 85 | }); 86 | }; 87 | -------------------------------------------------------------------------------- /test/karma.shim.js: -------------------------------------------------------------------------------- 1 | const importAll = ctx => ctx.keys().forEach(ctx); 2 | importAll(require.context('../src', true, /\.test\.ts/)); 3 | -------------------------------------------------------------------------------- /test/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | module.exports = (() => { 5 | const config = {}; 6 | 7 | config.devtool = 'inline-source-map'; 8 | 9 | config.resolve = { 10 | extensions: ['.ts', '.js'], 11 | }; 12 | 13 | config.module = { 14 | rules: [ 15 | { 16 | test: /\.ts$/, 17 | use: { 18 | loader: 'istanbul-instrumenter-loader', 19 | options: { esModules: true }, 20 | }, 21 | exclude: /node_modules|\.test\.ts$/, 22 | enforce: 'post', 23 | }, 24 | { 25 | test: /\.ts$/, 26 | use: { 27 | loader: 'ts-loader', 28 | options: { 29 | compilerOptions: { 30 | inlineSourceMap: true, 31 | sourceMap: false, 32 | }, 33 | }, 34 | }, 35 | }, 36 | ], 37 | }; 38 | 39 | return config; 40 | })(); 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "strict": true, 5 | "noImplicitReturns": true, 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "skipLibCheck": true, 9 | "noStrictGenericChecks": true, 10 | "module": "commonjs", 11 | "target": "es5", 12 | "outDir": "dist", 13 | "lib": [ 14 | "es6", 15 | "dom" 16 | ], 17 | "types": [ 18 | "mocha", 19 | "node", 20 | "chai-subset" 21 | ] 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tslint", 3 | "extends": ["tslint:recommended", "tslint-config-prettier"], 4 | "rules": { 5 | "object-literal-sort-keys": false, 6 | "no-unused-expression": false 7 | } 8 | } 9 | --------------------------------------------------------------------------------