├── .babelrc ├── .circleci └── config.yml ├── .eslintrc ├── .github └── pull_request_template.md ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── media ├── live-connection-demo.gif ├── tour-screenshot.png └── ycombinator.png ├── package.json ├── src ├── browser.js ├── common.js ├── connections │ ├── base.js │ ├── client.js │ ├── index.js │ ├── proxy.js │ └── server.js ├── errors.js ├── extension │ ├── background.js │ ├── content.js │ ├── img │ │ └── icon-32x32.png │ ├── manifest.json │ ├── popup.css │ ├── popup.html │ └── popup.js ├── index.js └── launchers.js ├── test ├── data │ ├── blank-page.html │ └── red-page.html ├── test-browsers.js ├── test-common.js ├── test-connections.js └── test-language-support.js ├── webpack ├── client.config.js ├── extension.config.js ├── index.js └── web-client.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["env", { 3 | "targets": { 4 | "node": "7.6" 5 | } 6 | }]], 7 | "plugins": [ 8 | ["transform-builtin-extend", { 9 | globals: ["Error"] 10 | }], 11 | "transform-class-properties", 12 | "transform-object-rest-spread" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: ~/remote-browser 3 | docker: 4 | - image: circleci/node:latest-browsers 5 | 6 | whitelist: &whitelist 7 | paths: 8 | - .babelrc 9 | - .eslintrc 10 | - .gitignore 11 | - dist/* 12 | - LICENSE 13 | - README.md 14 | - package.json 15 | - src/* 16 | - test/* 17 | - webpack/* 18 | - yarn.lock 19 | 20 | version: 2 21 | jobs: 22 | checkout: 23 | <<: *defaults 24 | steps: 25 | - checkout 26 | - run: 27 | name: Update Yarn 28 | command: 'sudo npm install -g yarn@latest' 29 | - restore_cache: 30 | key: dependency-cache-{{ checksum "yarn.lock" }} 31 | - run: 32 | name: Install Dependencies 33 | command: yarn install 34 | - save_cache: 35 | key: dependency-cache-{{ checksum "yarn.lock" }} 36 | paths: 37 | - ./node_modules 38 | - persist_to_workspace: 39 | root: ~/remote-browser 40 | paths: 41 | <<: *whitelist 42 | 43 | build: 44 | <<: *defaults 45 | steps: 46 | - attach_workspace: 47 | at: ~/remote-browser 48 | - restore_cache: 49 | key: dependency-cache-{{ checksum "yarn.lock" }} 50 | - run: 51 | name: Build 52 | command: | 53 | yarn build 54 | yarn build:web 55 | - persist_to_workspace: 56 | root: ~/remote-browser 57 | paths: 58 | <<: *whitelist 59 | 60 | test: 61 | <<: *defaults 62 | steps: 63 | - attach_workspace: 64 | at: ~/remote-browser 65 | - restore_cache: 66 | key: dependency-cache-{{ checksum "yarn.lock" }} 67 | - run: 68 | name: Install Firefox and Geckodriver 69 | command: | 70 | # Kind of gross that we have to do this, but the CircleCI image is ancient. 71 | curl -L "https://download.mozilla.org/?product=firefox-latest&os=linux64&lang=en-US" | sudo tar -C /opt -jx 72 | sudo ln -s /opt/firefox/firefox node_modules/.bin/firefox 73 | - run: 74 | name: Test 75 | command: yarn test 76 | 77 | deploy: 78 | <<: *defaults 79 | steps: 80 | - attach_workspace: 81 | at: ~/remote-browser 82 | - run: 83 | name: Write NPM Token to ~/.npmrc 84 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 85 | - run: 86 | name: Install dot-json package 87 | command: npm install dot-json 88 | - run: 89 | name: Write version to package.json 90 | command: $(yarn bin)/dot-json package.json version ${CIRCLE_TAG:1} 91 | - run: 92 | name: Move files from dist into the project root 93 | command: | 94 | mv dist/* ./ 95 | rm -rf dist/ 96 | - run: 97 | name: Publish to NPM 98 | command: npm publish --access=public 99 | 100 | 101 | workflows: 102 | version: 2 103 | 104 | build: 105 | jobs: 106 | - checkout 107 | - build: 108 | filters: 109 | tags: 110 | ignore: /v[0-9]+(\.[0-9]+)*/ 111 | requires: 112 | - checkout 113 | - test: 114 | filters: 115 | tags: 116 | ignore: /v[0-9]+(\.[0-9]+)*/ 117 | requires: 118 | - build 119 | 120 | release: 121 | jobs: 122 | - checkout: 123 | filters: 124 | tags: 125 | only: /v[0-9]+(\.[0-9]+)*/ 126 | branches: 127 | ignore: /.*/ 128 | - build: 129 | filters: 130 | tags: 131 | only: /v[0-9]+(\.[0-9]+)*/ 132 | branches: 133 | ignore: /.*/ 134 | requires: 135 | - checkout 136 | - test: 137 | filters: 138 | tags: 139 | only: /v[0-9]+(\.[0-9]+)*/ 140 | branches: 141 | ignore: /.*/ 142 | requires: 143 | - build 144 | - deploy: 145 | filters: 146 | tags: 147 | only: /v[0-9]+(\.[0-9]+)*/ 148 | branches: 149 | ignore: /.*/ 150 | requires: 151 | - test 152 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "mocha": true, 7 | "webextensions": true 8 | }, 9 | "extends": "airbnb-base", 10 | "parser": "babel-eslint" 11 | } 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | - [ ] By placing an `X` in the preceding checkbox, I verify that I have signed the [Contributor License Agreement](https://www.clahub.com/agreements/intoli/remote-browser) 2 | 3 | *Replace this text with either "Closes #N" or "Connects #N," where N is the corresponding GitHub issue number.* 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor Files 2 | .tern-port 3 | 4 | # Cache 5 | cache 6 | 7 | # Build outputs 8 | build 9 | dist 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (http://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Typescript v1 declaration files 50 | typings/ 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional REPL history 59 | .node_repl_history 60 | 61 | # Output of 'npm pack' 62 | *.tgz 63 | 64 | # Yarn Integrity file 65 | .yarn-integrity 66 | 67 | # dotenv environment variables file 68 | .env 69 | 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Contributions are welcome, but please follow these contributor guidelines: 4 | 5 | - Create an issue on [the issue tracker](https://github.com/intoli/remote-browser/issues/new) to discuss potential changes before submitting a pull request. 6 | - Include at least one test to cover any new functionality or bug fixes. 7 | - Make sure that all of your tests are passing and that there are no merge conflicts. 8 | - You agree to sign the project's [Contributor License Agreement](https://www.clahub.com/agreements/intoli/remote-browser). 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2-Clause BSD License 2 | 3 | Copyright 2017-present - Intoli, LLC 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Remote Browser 2 | 3 | Tweet 5 | 6 | Share on Facebook 8 | 9 | Share on Reddit 11 | 12 | Share on Hacker News 14 |

15 | 16 |

17 | 18 | Build Status 20 | 21 | License 23 | 24 | NPM Version 26 |

27 | 28 | 29 | Remote Browser is a library for controlling web browsers like Chrome and Firefox programmatically using JavaScript. 30 | You've likely heard of similar browser automation frameworks before, such as [Puppeteer](https://github.com/GoogleChrome/puppeteer) and [Selenium](https://github.com/SeleniumHQ/selenium). 31 | Much like these other projects, Remote Browser can be used to accomplish a wide variety of tasks relating to UI testing, Server Side Rendering (SSR), and web scraping. 32 | What makes Remote Browser different from these other libraries is that it's built using standard cross-browser compatible technologies, and its primary goal is to facilitate interactions with existing APIs rather than to create a new one of its own. 33 | 34 | Remote Browser provides a minimalistic and lightweight framework for automating browsers using vanilla [JavaScript](https://tc39.github.io/ecma262/), [HTML browsing contexts](https://html.spec.whatwg.org/multipage/window-object.html#the-window-object), and the [Web Extensions API](https://developer.mozilla.org/en-US/Add-ons/WebExtensions). 35 | If you're already familiar with these technologies, then you already know almost everything that you need to use Remote Browser. 36 | If not, then there are vast learning resources out there, like [The Mozilla Developer Network (MDN)](https://developer.mozilla.org/en-US/docs/Web), which can get you up to speed in no time. 37 | Be sure to check out the [Introduction](#introduction), the [Interactive Tour](https://intoli.com/tour), and [Usage Examples](#usage-examples) to learn about how Remote Browser makes it easy to use these technologies. 38 | 39 | 40 | ## Table of Contents 41 | 42 | - [Introduction](#introduction) - A detailed explanation of what Remote Browser is and the core concepts behind the project. 43 | - [Interactive Tour](#interactive-tour) 44 | - [Installation](#installation) - Instructions for installing Remote Browser. 45 | - [Usage Examples](#usage-examples) - Illustrative examples of how Remote Browser can be used. 46 | - [Development](#development) - Instructions for setting up the development environment. 47 | - [Contributing](#contributing) - Guidelines for contributing. 48 | - [License](#license) - License details for the project. 49 | 50 | 51 | ## Introduction 52 | 53 | The core technology that makes Remote Browser possible is the [Web Extensions API](https://developer.mozilla.org/en-US/Add-ons/WebExtensions). 54 | This API is what allows third party browser addons to extend and modify the capabilities of browsers such as Firefox, Chrome, Edge, and Opera. 55 | If you've never written a browser extension before, then you might be surprised at just how powerful this API is. 56 | Creating tabs and interacting with pages is just the beginning; it's also possible to [intercept and modify network requests/responses](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest), [create and control containerized sessions within a single browser instance](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/contextualIdentities), [take screenshots](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/captureVisibleTab), and [*much* more](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API). 57 | The central idea behind Remote Browser is that there's no need to reinvent the wheel when modern browsers *already* ship with an extremely powerful cross-browser compatible API that's suitable for automation tasks. 58 | 59 | Let's take a look at a quick example of how you would navigate to a tab and take a screenshot using Remote Browser. 60 | 61 | ```javascript 62 | import Browser from 'remote-browser'; 63 | 64 | (async () => { 65 | // Create and launch a new browser instance. 66 | const browser = new Browser(); 67 | await browser.launch(); 68 | 69 | // Directly access the Web Extensions API from a remote client. 70 | const tab = await browser.tabs.create({ url: 'https://intoli.com' }); 71 | const screenshot = await browser.tabs.captureVisibleTab(); 72 | })(); 73 | ``` 74 | 75 | On the surface, this probably looks pretty similar to examples from other browser automation frameworks. 76 | The difference is that [browser.tabs.create()](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/create) and [browser.tabs.captureVisibleTab](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/captureVisibleTab) aren't part of the Remote Browser API; they're part of the Web Extensions API. 77 | 78 | In a web extension, you would typically interact with the Web Extensions API through a global `browser` object. 79 | You could make a call to `browser.tabs.create()` in your extension's [background script](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/background), and it would create a new tab. 80 | Remote Browser lets you make this call from the environment where you're running your browser control code as though you were inside of an extension. 81 | The following three calls are actually all exactly equivalent with Remote Browser. 82 | 83 | ```javascript 84 | // Very explicit, we're specifying a function and argument to evaluate in the background page. 85 | await browser.evaluateInBackground(createProperties => ( 86 | browser.tabs.create(createProperties) 87 | ), { url: 'https://intoli.com' }); 88 | 89 | // A bit of syntactic sugar, we can omit ".evaluateInBackground" and the same thing happens. 90 | await browser(createProperties => ( 91 | browser.tabs.create(createProperties) 92 | ), { url: 'https://intoli.com' }); 93 | 94 | // A lot of syntactic sugar, the function and explicit argument get constructed automatically. 95 | await browser.tabs.create({ url: 'https://intoli.com' }); 96 | ``` 97 | 98 | It's mostly immediately clear what's really happening here with the the first `browser.evaluateInBackground()` call. 99 | A function and it's argument are being transmitted to the background script context of a web extension where they're evaluated. 100 | The next two calls just rip out successive layers of boilerplate, but they're doing the exact same thing. 101 | 102 | Similarly, we can evaluate code in the context of a tab in the browser. 103 | The syntax here is very similar to how we evaluate code in the background script, we just need to additionally specify which tab we're interested in. 104 | The following two calls are also exactly equivalent. 105 | 106 | ```javascript 107 | // Evaluate the function in the content script context for the identified by `tab.id`. 108 | await browser.evaluateInContent(tab.id, () => ( 109 | document.innerHTML = 'hi!'; 110 | )); 111 | 112 | // A shorthand for first accessing a specific tab, and then evaluating code in it. 113 | await browser[tab.id](() => document.body.innerHTML = 'hi!'); 114 | ``` 115 | 116 | At this point, you've seen nearly all of the syntax that Remote Browser provides. 117 | It makes it really easy to evaluate code in different contexts, and lets you use the browser APIs to control and interact with the browser itself. 118 | 119 | 120 | ## Interactive Tour 121 | 122 | 123 |

124 | 125 | 126 | 127 |

128 | 129 | You can learn more about how Remote Browser works in [the interactive tour](https://intoli.com/tour). 130 | The tour provides an interactive environment where you can run code examples in your browser without needing to install any software. 131 | It expands upon some of the fundamental concepts behind Remote Browser, and demonstrates how the library can be used in real-world scenarios. 132 | 133 | 134 | ## Usage Examples 135 | 136 | ### Connecting to a Running Browser 137 | 138 | All of the browser control code for Remote Browser is implemented as a cross-browser compatible web extension. 139 | When you execute `Browser.launch()`, it simply runs a browser instance with the extension installed and passes it the parameters necessary to connect to the remote client. 140 | You can also install this same extension in your day-to-day browser, and tell it to connect to a remote client manually. 141 | This can be a very useful debugging tool while you're developing scripts, or as a means to quickly automate simple tasks without needing to create a whole browser extension from scratch. 142 | 143 | Calling `Browser.listen()` on a new browser instance will cause it to listen on an open port for a connection from an already running browser. 144 | It will return the port that it's listening on, and wait for you to initialize a connection from a browser that has the Remote Browser extension installed. 145 | 146 | ```javascript 147 | const browser = new Browser(); 148 | const port = await browser.listen(); 149 | console.log(`Listening on port: ${port}`); 150 | ``` 151 | 152 | The connection can be initiated from within the browser using the Remote Browser extension popup. 153 | Here's an example of the connection process. 154 | 155 | ![Connecting the Client to a Live Browser](media/live-connection-demo.gif) 156 | 157 | The browser interactions that we do in this video aren't particularly useful, but you have the full power of Remote Browser at your fingertips once you're connected. 158 | You could, for instance, use [web extension alarms](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/alarms) to schedule arbitrary tasks to run in the browser at specified times or intervals. 159 | These tasks would be scheduled within the browser itself, so you could configure code to run even after the client has disconnected. 160 | 161 | 162 | ## Installation 163 | 164 | Remote Browser is available as an [npm package](https://www.npmjs.com/package/remote-browser), and the latest version can be installed by running the following. 165 | 166 | ```bash 167 | yarn add remote-browser 168 | ``` 169 | 170 | It's possible to use Remote Browser as a client for browser sessions on remote servers, but you'll almost certainly want a local browser installed when you're first getting started. 171 | We recommend [installing Firefox](https://www.mozilla.org/firefox), even if it's not your day-to-day browser, because it has a more complete implementation of the Web Extensions API than other browsers. 172 | It's additionally set as the default in Remote Browser, so it will allow you to run the usage examples without changing any of the configuration options. 173 | 174 | 175 | ## Development 176 | 177 | To get started on development, you simply need to clone the repository and install the project dependencies. 178 | 179 | ```bash 180 | # Clone the repository. 181 | git clone https://github.com/intoli/remote-browser.git 182 | cd remote-browser 183 | 184 | # Install the dependencies. 185 | yarn install 186 | 187 | # Build the project. 188 | yarn build 189 | 190 | # Run the tests. 191 | yarn test 192 | ``` 193 | 194 | ## Contributing 195 | 196 | Contributions are welcome, but please follow these contributor guidelines outlined in [CONTRIBUTING.md](CONTRIBUTING.md). 197 | 198 | 199 | ## License 200 | 201 | Remote Browser is licensed under a [BSD 2-Clause License](LICENSE) and is copyright [Intoli, LLC](https://intoli.com). 202 | -------------------------------------------------------------------------------- /media/live-connection-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intoli/remote-browser/b8c2a9ed9a024b6024efbfc331b174d57fe58870/media/live-connection-demo.gif -------------------------------------------------------------------------------- /media/tour-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intoli/remote-browser/b8c2a9ed9a024b6024efbfc331b174d57fe58870/media/tour-screenshot.png -------------------------------------------------------------------------------- /media/ycombinator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intoli/remote-browser/b8c2a9ed9a024b6024efbfc331b174d57fe58870/media/ycombinator.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remote-browser", 3 | "version": "0.0.15", 4 | "description": "A low-level browser automation framework built on top of the Web Extensions API standard. ", 5 | "main": "index.js", 6 | "repository": "git@github.com:intoli/remote-browser.git", 7 | "author": "Intoli, LLC ", 8 | "license": "BSD", 9 | "scripts": { 10 | "build": "NODE_ENV=production webpack --config webpack", 11 | "build:web": "NODE_ENV=production webpack --config webpack/web-client.config.js", 12 | "postversion": "git push && git push --tags", 13 | "test": "npm run build && NODE_ENV=test mocha --exit --require babel-core/register", 14 | "test-fast": "NODE_ENV=test mocha --exit --require babel-core/register --invert --grep Browser", 15 | "watch": "NODE_ENV=development webpack --watch --config webpack" 16 | }, 17 | "devDependencies": { 18 | "babel-core": "^6.26.0", 19 | "babel-eslint": "^8.0.2", 20 | "babel-loader": "^7.1.2", 21 | "babel-plugin-transform-builtin-extend": "^1.1.2", 22 | "babel-plugin-transform-class-properties": "^6.24.1", 23 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 24 | "babel-preset-env": "^1.6.1", 25 | "clean-webpack-plugin": "^0.1.17", 26 | "copy-webpack-plugin": "^4.2.1", 27 | "eslint": "^4.11.0", 28 | "eslint-config-airbnb": "^16.1.0", 29 | "eslint-loader": "^1.9.0", 30 | "eslint-plugin-import": "^2.8.0", 31 | "imports-loader": "^0.7.1", 32 | "jimp": "^0.2.28", 33 | "mocha": "^4.0.1", 34 | "null-loader": "^0.1.1", 35 | "source-map-support": "^0.5.0", 36 | "webextension-polyfill": "^0.2.1", 37 | "webpack": "^3.8.1", 38 | "webpack-chrome-extension-reloader": "^0.6.6" 39 | }, 40 | "dependencies": { 41 | "chromedriver": "^2.37.0", 42 | "express": "^4.16.2", 43 | "express-ws": "^3.0.0", 44 | "geckodriver": "^1.11.0", 45 | "isomorphic-fetch": "^2.2.1", 46 | "portfinder": "^1.0.13", 47 | "selenium-webdriver": "^4.0.0-alpha.1", 48 | "simple-websocket": "^5.1.0", 49 | "ws": "^3.3.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/browser.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | import fetch from 'isomorphic-fetch'; 4 | 5 | import { serializeFunction } from './common'; 6 | import { Client, ConnectionProxy } from './connections'; 7 | import { ConnectionError, RemoteError } from './errors'; 8 | import { launchChrome, launchFirefox } from './launchers'; 9 | 10 | 11 | class CallableProxy extends Function { 12 | constructor(handler) { 13 | super(); 14 | return new Proxy(this, handler); 15 | } 16 | } 17 | 18 | 19 | class ApiProxy extends CallableProxy { 20 | constructor(evaluator, objectName) { 21 | super({ 22 | apply: async (target, thisArg, argumentsList) => ( 23 | evaluator( 24 | // eslint-disable-next-line no-template-curly-in-string 25 | 'async (objectName, argumentsList) => eval(`${objectName}(...window.JSONfn.parse(${JSON.stringify(window.JSONfn.stringify(argumentsList))}))`)', 26 | objectName, 27 | argumentsList, 28 | ) 29 | ), 30 | get: (target, name) => ( 31 | new ApiProxy(evaluator, `${objectName}.${name}`) 32 | ), 33 | }); 34 | } 35 | } 36 | 37 | 38 | export default class Browser extends CallableProxy { 39 | constructor(options) { 40 | super({ 41 | apply: (target, thisArg, argumentsList) => ( 42 | this.evaluateInBackground(...argumentsList) 43 | ), 44 | get: (target, name) => { 45 | // Integer indices, refering to tab IDs. 46 | if (name && name.match && name.match(/^\d+$/)) { 47 | const evaluator = (...args) => this.evaluateInContent(parseInt(name, 10), ...args); 48 | evaluator.readyState = async (readyState) => { 49 | assert( 50 | ['loading', 'interactive', 'complete'].includes(readyState), 51 | 'Only "loading," "interactive," and "complete" are valid ready states.', 52 | ); 53 | return evaluator(desiredState => ( 54 | new Promise((resolve) => { 55 | let resolved = false; 56 | const states = ['loading', 'interactive', 'complete']; 57 | const handleReadyStateChange = () => { 58 | if (!resolved) { 59 | if (states.indexOf(desiredState) <= states.indexOf(document.readyState)) { 60 | document.removeEventListener('readystatechange', handleReadyStateChange); 61 | resolved = true; 62 | resolve(document.readyState); 63 | } 64 | } 65 | }; 66 | document.addEventListener('readystatechange', handleReadyStateChange); 67 | handleReadyStateChange(); 68 | }) 69 | ), readyState); 70 | }; 71 | return evaluator; 72 | } 73 | // Properties that are part of the `Browser` API. 74 | if (Reflect.has(target, name)) { 75 | return Reflect.get(target, name); 76 | } 77 | // Remote Web Extensions API properties. 78 | if (typeof name === 'string') { 79 | return new ApiProxy(this.evaluateInBackground, `browser.${name}`); 80 | } 81 | // Fall back to accessing the property, even if it's not defined. The node repl checks 82 | // some weird symbols and other things that would fail in `ApiProxy`. 83 | return Reflect.get(target, name); 84 | }, 85 | }); 86 | Object.defineProperty(this, 'name', { value: 'Browser' }); 87 | this.options = options; 88 | } 89 | 90 | evaluateInBackground = async (asyncFunction, ...args) => { 91 | const { error, result } = await this.client.send({ 92 | args, 93 | asyncFunction: serializeFunction(asyncFunction), 94 | }, { channel: 'evaluateInBackground' }); 95 | 96 | if (error) { 97 | throw new RemoteError(error.remoteError); 98 | } 99 | 100 | return result; 101 | }; 102 | 103 | evaluateInContent = async (tabId, asyncFunction, ...args) => { 104 | const { error, result } = await this.client.send({ 105 | args, 106 | asyncFunction: serializeFunction(asyncFunction), 107 | tabId, 108 | }, { channel: 'evaluateInContent' }); 109 | 110 | if (error) { 111 | throw new RemoteError(error.remoteError); 112 | } 113 | 114 | return result; 115 | }; 116 | 117 | launch = async (browser = 'firefox') => { 118 | assert( 119 | ['chrome', 'firefox', 'remote'].includes(browser), 120 | 'Only Chrome, Firefox, and Remote are supported right now.', 121 | ); 122 | 123 | // Handle launching remotely. 124 | const webBuild = typeof window !== 'undefined'; 125 | if (browser === 'remote' || webBuild) { 126 | await this.launchRemote(); 127 | return; 128 | } 129 | 130 | const launch = (browser === 'chrome' ? launchChrome : launchFirefox); 131 | const sessionId = 'default'; 132 | 133 | // Prepare the client and the proxy. 134 | await this.listen(sessionId); 135 | 136 | // Launch the browser with the correct arguments. 137 | this.driver = await launch(this.connectionUrl, this.sessionId); 138 | 139 | await this.connection; 140 | }; 141 | 142 | launchRemote = async () => { 143 | const remoteBrowserApiUrl = (typeof window === 'undefined' ? null : window.REMOTE_BROWSER_API_URL) 144 | || process.env.REMOTE_BROWSER_API_URL; 145 | if (!remoteBrowserApiUrl) { 146 | throw new Error(( 147 | 'You must specify a remote server using the REMOTE_BROWSER_API_URL environment variable. ' + 148 | 'This should be specified at build-time when building the web client. ' + 149 | 'It can also be specified as `window.REMOTE_BROWSER_API_URL` using `webpack.DefinePlugin()`.' 150 | )); 151 | } 152 | // We can use `http` or `https` in node, so we won't coerce anything if this is `null`. 153 | const secure = typeof window === 'undefined' ? null : 154 | window.location.protocol.startsWith('https'); 155 | 156 | let initializationUrl = remoteBrowserApiUrl; 157 | if (secure && initializationUrl.startsWith('http:')) { 158 | initializationUrl = `https:${initializationUrl.slice(5)}`; 159 | } else if (secure === false && initializationUrl.startsWith('https:')) { 160 | initializationUrl = `http:${initializationUrl.slice(6)}`; 161 | } 162 | 163 | const response = await (await fetch(initializationUrl)).json(); 164 | this.connectionUrl = response.url; 165 | if (response.error) { 166 | throw new ConnectionError(response.error); 167 | } 168 | if (!response.url || !response.sessionId) { 169 | throw new Error('Invalid initialization response from the tour backend'); 170 | } 171 | 172 | if (secure && response.url.startsWith('ws:')) { 173 | this.connectionUrl = `wss:${response.url.slice(3)}`; 174 | } else if (secure === false && response.url.startsWith('wss:')) { 175 | this.connectionUrl = `ws:${response.url.slice(4)}`; 176 | } 177 | this.sessionId = response.sessionId; 178 | 179 | await this.negotiateConnection(); 180 | }; 181 | 182 | listen = async (sessionId = 'default') => { 183 | // Set up the proxy and connect to it. 184 | this.proxy = new ConnectionProxy(); 185 | this.port = await this.proxy.listen(); 186 | this.connectionUrl = `ws://localhost:${this.port}/`; 187 | this.sessionId = sessionId; 188 | 189 | await this.negotiateConnection(); 190 | 191 | return this.port; 192 | }; 193 | 194 | negotiateConnection = async () => { 195 | // Note that this function is really for internal use only. 196 | // Prepare for the initial connection from the browser. 197 | this.client = new Client(); 198 | let proxyConnection; 199 | this.connection = new Promise((resolve) => { 200 | const channel = 'initialConnection'; 201 | const handleInitialConnection = () => { 202 | this.client.unsubscribe(handleInitialConnection, { channel }); 203 | resolve(); 204 | }; 205 | this.client.subscribe(handleInitialConnection, { channel }); 206 | proxyConnection = this.client.connect(this.connectionUrl, 'user', this.sessionId); 207 | }); 208 | await proxyConnection; 209 | }; 210 | 211 | quit = async () => { 212 | // Close all of the windows. 213 | if (Reflect.has(this, 'client')) { 214 | // We'll never get a response here, so it needs to be sent off asynchronously. 215 | this.evaluateInBackground(async () => ( 216 | Promise.all((await browser.windows.getAll()) 217 | .map(({ id }) => browser.windows.remove(id))) 218 | )); 219 | } 220 | 221 | if (Reflect.has(this, 'driver')) { 222 | await this.driver.quit(); 223 | } 224 | 225 | if (Reflect.has(this, 'proxy')) { 226 | await this.proxy.close(); 227 | } 228 | 229 | if (Reflect.has(this, 'client')) { 230 | await this.client.close(); 231 | } 232 | }; 233 | } 234 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | import { TimeoutError } from './errors'; 2 | 3 | 4 | export const serializeFunction = (f) => { 5 | const serialized = f.toString(); 6 | 7 | // Safari serializes async arrow functions with an invalid `function` keyword. 8 | // This needs to be removed in order for the function to be interpretable. 9 | const safariPrefix = 'async function '; 10 | if (serialized.startsWith(safariPrefix)) { 11 | const arrowIndex = serialized.indexOf('=>'); 12 | const bracketIndex = serialized.indexOf('{'); 13 | if (arrowIndex > -1 && (bracketIndex === -1 || arrowIndex < bracketIndex)) { 14 | return `async ${serialized.slice(safariPrefix.length)}`; 15 | } 16 | } 17 | 18 | return serialized; 19 | }; 20 | 21 | const nativeCodeRegex = /{\s*\[native code\]\s*}$/i; 22 | const serializedFunctionPrefix = '__remote-browser-serialized-function__:'; 23 | export const JSONfn = { 24 | parse(string) { 25 | return JSON.parse(string, (key, value) => { 26 | if (typeof value !== 'string' || !value.startsWith(serializedFunctionPrefix)) { 27 | return value; 28 | } 29 | let functionDefinition = value.slice(serializedFunctionPrefix.length); 30 | if (nativeCodeRegex.test(functionDefinition)) { 31 | functionDefinition = functionDefinition.replace( 32 | nativeCodeRegex, 33 | '{ console.warn(\'Native code could not be serialized, and was removed.\'); }', 34 | ); 35 | } 36 | // eslint-disable-next-line no-eval 37 | return eval(`(${functionDefinition})`); 38 | }); 39 | }, 40 | stringify(object) { 41 | return JSON.stringify(object, (key, value) => { 42 | if (value instanceof Function || typeof value === 'function') { 43 | return `${serializedFunctionPrefix}${serializeFunction(value)}`; 44 | } 45 | return value; 46 | }); 47 | }, 48 | }; 49 | 50 | 51 | export class TimeoutPromise extends Promise { 52 | constructor(executor, timeout = 30000) { 53 | super((resolve, revoke) => { 54 | let completed = false; 55 | const guard = f => (...params) => { 56 | if (!completed) { 57 | completed = true; 58 | return f(...params); 59 | } 60 | return null; 61 | }; 62 | 63 | setTimeout(guard(() => { 64 | revoke(new TimeoutError(`Promise timed out after ${timeout} ms.`)); 65 | }), timeout); 66 | executor(guard(resolve), guard(revoke)); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/connections/base.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | import { JSONfn } from '../common'; 4 | import { TimeoutError } from '../errors'; 5 | 6 | 7 | export default class ConnectionBase extends EventEmitter { 8 | constructor() { 9 | super(); 10 | this.messageIndex = 0; 11 | this.messageTimeout = 30000; 12 | this.pendingMessageResolves = {}; 13 | this.pendingPingResolves = []; 14 | this.pingTimeout = 30000; 15 | this.subscriptions = {}; 16 | this.ws = null; 17 | } 18 | 19 | attachWebSocket = (ws) => { 20 | if (this.ws) { 21 | this.ws.removeAllListeners(); 22 | } 23 | this.ws = ws; 24 | this.ws.on('data', this.onData); 25 | 26 | // The `connection` events are handled by the derived classes. 27 | this.ws.on('close', () => { this.emit('close'); }); 28 | this.ws.on('error', (error) => { this.emit('error', error); }); 29 | 30 | return ws; 31 | }; 32 | 33 | onData = async (data) => { 34 | // Handle ping/pong keep alives. 35 | if (data.length === 4) { 36 | const text = data.toString ? data.toString() : data; 37 | if (text === 'ping') { 38 | this.ws.send('pong'); 39 | return; 40 | } else if (text === 'pong') { 41 | if (this.pendingPingResolves.length) { 42 | this.pendingPingResolves.shift()(text); 43 | } 44 | return; 45 | } 46 | } 47 | 48 | const message = JSONfn.parse(data); 49 | 50 | // Handle responses to messages that we originated. 51 | if (message.response) { 52 | if (this.pendingMessageResolves[message.messageIndex]) { 53 | this.pendingMessageResolves[message.messageIndex](message.data); 54 | delete this.pendingMessageResolves[message.messageIndex]; 55 | } 56 | return; 57 | } 58 | 59 | // Handle messages that we did not originate. 60 | const responseData = await this.subscriptions[message.channel](message.data); 61 | this.ws.send(JSONfn.stringify({ 62 | ...message, 63 | data: responseData, 64 | response: true, 65 | })); 66 | }; 67 | 68 | ping = async () => ( 69 | new Promise((resolve, revoke) => { 70 | this.pendingPingResolves.push(resolve); 71 | this.ws.send('ping'); 72 | setTimeout(() => { 73 | const index = this.pendingPingResolves.indexOf(resolve); 74 | if (index > -1) { 75 | this.pendingPingResolves.splice(index); 76 | revoke(); 77 | } 78 | }, this.pingTimeout); 79 | }) 80 | ); 81 | 82 | send = async (data, { channel, timeout } = {}) => { 83 | this.messageIndex += 1; 84 | const message = { 85 | data, 86 | channel, 87 | messageIndex: this.messageIndex, 88 | response: false, 89 | }; 90 | 91 | const { messageIndex } = this; 92 | return new Promise((resolve, revoke) => { 93 | this.pendingMessageResolves[messageIndex] = resolve; 94 | this.ws.send(JSONfn.stringify(message)); 95 | setTimeout(() => { 96 | if (this.pendingMessageResolves[messageIndex]) { 97 | revoke(new TimeoutError('No websocket response was received within the timeout.')); 98 | delete this.pendingMessageResolves[messageIndex]; 99 | } 100 | }, timeout || this.messageTimeout); 101 | }); 102 | }; 103 | 104 | subscribe = (callback, { channel } = {}) => { 105 | this.subscriptions[channel] = callback; 106 | }; 107 | 108 | unsubscribe = (callback, { channel } = {}) => { 109 | delete this.subscriptions[channel]; 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /src/connections/client.js: -------------------------------------------------------------------------------- 1 | import WebSocket from 'simple-websocket'; 2 | 3 | import { JSONfn } from '../common'; 4 | import ConnectionBase from './base'; 5 | 6 | 7 | export default class Client extends ConnectionBase { 8 | close = async () => { 9 | if (this.ws) { 10 | this.ws.destroy(); 11 | } 12 | Object.keys(this.pendingMessageResolves) 13 | .forEach((messageId) => { 14 | this.pendingMessageResolves[messageId]({}); 15 | delete this.pendingMessageResolves[messageId]; 16 | }); 17 | }; 18 | 19 | connect = async (url, clientType, sessionId = 'default') => ( 20 | new Promise((resolve, revoke) => { 21 | this.sessionId = sessionId; 22 | const ws = new WebSocket(url); 23 | let connected = false; 24 | ws.once('connect', () => { 25 | ws.once('data', (data) => { 26 | connected = true; 27 | const { success } = JSONfn.parse(data); 28 | if (success) { 29 | this.emit('connection'); 30 | this.attachWebSocket(ws); 31 | resolve(); 32 | } else { 33 | revoke(); 34 | } 35 | }); 36 | ws.send(JSONfn.stringify({ clientType, sessionId })); 37 | }); 38 | ws.once('error', (error) => { 39 | if (!connected) { 40 | revoke(error); 41 | } 42 | }); 43 | }) 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/connections/index.js: -------------------------------------------------------------------------------- 1 | import Client from './client'; 2 | import ConnectionProxy from './proxy'; 3 | import Server from './server'; 4 | 5 | export { Client, ConnectionProxy, Server }; 6 | -------------------------------------------------------------------------------- /src/connections/proxy.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | import portfinder from 'portfinder'; 4 | import WebSocket from 'ws'; 5 | 6 | import { JSONfn } from '../common'; 7 | 8 | 9 | export default class ConnectionProxy extends EventEmitter { 10 | constructor() { 11 | super(); 12 | 13 | // We'll send messages between 14 | this.clientTypes = ['extension', 'user']; 15 | this.webSockets = { extension: {}, user: {} }; 16 | 17 | this.pendingMessages = { extension: {}, user: {} }; 18 | } 19 | 20 | close = async () => { 21 | // Terminate all of the existing connections. 22 | this.clientTypes.forEach((clientType) => { 23 | Object.values(this.webSockets[clientType]).forEach((ws) => { 24 | if (ws.readyState === 1) { 25 | ws.close(); 26 | } 27 | ws.terminate(); 28 | }); 29 | }); 30 | 31 | // Close the actual server. 32 | return new Promise(resolve => this.server.close(resolve)); 33 | }; 34 | 35 | listen = async (port) => { 36 | const connectionPort = port || await portfinder.getPortPromise(); 37 | this.server = new WebSocket.Server({ 38 | clientTracking: true, 39 | host: 'localhost', 40 | port: connectionPort, 41 | }); 42 | return new Promise((resolve) => { 43 | this.server.once('listening', () => resolve(connectionPort)); 44 | this.server.on('connection', (ws) => { 45 | ws.once('message', (initialData) => { 46 | const { clientType, sessionId } = JSONfn.parse(initialData); 47 | if (sessionId && this.clientTypes.includes(clientType)) { 48 | // Clean up any existing websockets and store the new one. 49 | const existingWebSocket = this.webSockets[clientType][sessionId]; 50 | if (existingWebSocket && existingWebSocket !== ws) { 51 | if (existingWebSocket.readyState === 1) { 52 | existingWebSocket.close(); 53 | } 54 | existingWebSocket.terminate(); 55 | } 56 | this.webSockets[clientType][sessionId] = ws; 57 | 58 | // Proxy messages between the pairs of clients with the same session ID. 59 | const otherClientType = clientType === this.clientTypes[0] ? 60 | this.clientTypes[1] : this.clientTypes[0]; 61 | ws.on('message', (data) => { 62 | const otherWebSocket = this.webSockets[otherClientType][sessionId]; 63 | if (otherWebSocket && otherWebSocket.readyState === 1) { 64 | otherWebSocket.send(data); 65 | } else { 66 | const pendingMessages = this.pendingMessages[otherClientType][sessionId] || []; 67 | pendingMessages.push(data); 68 | this.pendingMessages[otherClientType][sessionId] = pendingMessages; 69 | } 70 | }); 71 | 72 | // Clean up if the connection is lost. 73 | ws.on('close', () => { 74 | ws.terminate(); 75 | if (this.webSockets[clientType][sessionId] === ws) { 76 | delete this.webSockets[clientType][sessionId]; 77 | } 78 | }); 79 | 80 | // Report success. 81 | ws.send(JSONfn.stringify({ success: true }), () => { 82 | // Write out any pending messages afterwards. 83 | const pendingMessages = this.pendingMessages[clientType][sessionId] || []; 84 | delete this.pendingMessages[clientType][sessionId]; 85 | if (pendingMessages.length) { 86 | let promise = Promise.resolve(); 87 | pendingMessages.forEach((message) => { 88 | promise = promise.then(() => ( 89 | new Promise(sendResolve => ws.send(message, sendResolve)) 90 | )); 91 | }); 92 | } 93 | }); 94 | } else { 95 | // Report failure. 96 | ws.send(JSONfn.stringify({ success: false }), () => { 97 | ws.terminate(); 98 | }); 99 | } 100 | }); 101 | }); 102 | }); 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/connections/server.js: -------------------------------------------------------------------------------- 1 | import portfinder from 'portfinder'; 2 | import WebSocketServer from 'simple-websocket/server'; 3 | 4 | import ConnectionBase from './base'; 5 | 6 | 7 | export default class Server extends ConnectionBase { 8 | close = async () => (new Promise((resolve, revoke) => { 9 | if (this.ws) { 10 | this.ws.destroy(); 11 | } 12 | this.server.close((error) => { 13 | if (error) { 14 | revoke(error); 15 | } else { 16 | resolve(); 17 | } 18 | }); 19 | })); 20 | 21 | listen = async () => new Promise(async (resolve, revoke) => { 22 | this.port = await portfinder.getPortPromise(); 23 | this.server = new WebSocketServer({ 24 | host: 'localhost', 25 | port: this.port, 26 | }); 27 | 28 | // eslint-disable-next-line no-underscore-dangle 29 | this.server._server.once('listening', (error) => { 30 | if (error) { 31 | revoke(error); 32 | } else { 33 | resolve(this.port); 34 | } 35 | }); 36 | 37 | this.server.on('connection', (ws) => { 38 | this.emit('connection'); 39 | this.attachWebSocket(ws); 40 | }); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | export class ConnectionError extends Error { 2 | constructor(...params) { 3 | super(...params); 4 | if (Error.captureStackTrace) { 5 | Error.captureStackTrace(this, ConnectionError); 6 | } 7 | } 8 | } 9 | 10 | 11 | export class RemoteError extends Error { 12 | constructor(error, message) { 13 | super(message || error.message); 14 | if (error && error.remoteError) { 15 | this.remoteError = error.remoteError; 16 | } else if (error instanceof Error) { 17 | this.remoteError = {}; 18 | let object = error; 19 | while (object instanceof Error) { 20 | Object.getOwnPropertyNames(object).forEach((name) => { 21 | this.remoteError[name] = error[name]; 22 | }); 23 | object = Object.getPrototypeOf(object); 24 | } 25 | } else { 26 | this.remoteError = error; 27 | } 28 | } 29 | 30 | toJSON = () => ({ 31 | name: 'RemoteError', 32 | remoteError: this.remoteError, 33 | }); 34 | } 35 | 36 | 37 | export class TimeoutError extends Error { 38 | constructor(...params) { 39 | super(...params); 40 | if (Error.captureStackTrace) { 41 | Error.captureStackTrace(this, TimeoutError); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/extension/background.js: -------------------------------------------------------------------------------- 1 | import { JSONfn } from '../common'; 2 | import Client from '../connections/client'; 3 | import { RemoteError } from '../errors'; 4 | 5 | 6 | window.JSONfn = JSONfn; 7 | 8 | class Background { 9 | constructor() { 10 | this.client = new Client(); 11 | 12 | // Maintain a registry of open ports with the content scripts. 13 | this.tabMessageId = 0; 14 | this.tabMessageResolves = {}; 15 | this.tabMessageRevokes = {}; 16 | this.tabPorts = {}; 17 | this.tabPortPendingRequests = {}; 18 | this.tabPortResolves = {}; 19 | browser.runtime.onConnect.addListener((port) => { 20 | if (port.name === 'contentScriptConnection') { 21 | this.addTabPort(port); 22 | } 23 | }); 24 | 25 | // Handle evaluation requests. 26 | this.client.subscribe(async ({ args, asyncFunction }) => ( 27 | Promise.resolve() 28 | // eslint-disable-next-line no-eval 29 | .then(() => eval(`(${asyncFunction}).apply(null, window.JSONfn.parse(${JSON.stringify(window.JSONfn.stringify(args))}))`)) 30 | .then(result => ({ result })) 31 | .catch(error => ({ error: new RemoteError(error) })) 32 | ), { channel: 'evaluateInBackground' }); 33 | this.client.subscribe(async ({ args, asyncFunction, tabId }) => ( 34 | Promise.resolve() 35 | // eslint-disable-next-line no-eval 36 | .then(() => this.sendToTab(tabId, { args, asyncFunction, channel: 'evaluateInContent' })) 37 | .then(result => ({ result })) 38 | .catch(error => ({ error: new RemoteError(error) })) 39 | ), { channel: 'evaluateInContent' }); 40 | 41 | // Emit and handle connection status events. 42 | this.connectionStatus = 'disconnected'; 43 | this.client.on('connection', () => { 44 | this.connectionStatus = 'connected'; 45 | this.broadcastConnectionStatus(); 46 | }); 47 | this.client.on('close', () => { 48 | this.connectionStatus = 'disconnected'; 49 | this.broadcastConnectionStatus(); 50 | }); 51 | this.client.on('error', () => { 52 | this.connectionStatus = 'error'; 53 | this.broadcastConnectionStatus(); 54 | }); 55 | 56 | // Listen for connection status requests from the popup. 57 | browser.runtime.onMessage.addListener((request) => { 58 | if (request.channel === 'connectionStatusRequest') { 59 | this.broadcastConnectionStatus(); 60 | } 61 | }); 62 | 63 | // Listen for connection requests from the popup browser action. 64 | browser.runtime.onMessage.addListener(async (request) => { 65 | if (request.channel === 'connectionRequest') { 66 | await this.connect(request.url, request.sessionId); 67 | } 68 | }); 69 | browser.runtime.onMessage.addListener(async (request) => { 70 | if (request.channel === 'disconnectionRequest') { 71 | await this.client.close(); 72 | } 73 | }); 74 | } 75 | 76 | addTabPort = (port) => { 77 | // Store the port. 78 | const tabId = port.sender.tab.id; 79 | this.tabPorts[tabId] = port; 80 | 81 | // Handle incoming messages. 82 | port.onMessage.addListener((request) => { 83 | const resolve = this.tabMessageResolves[request.id]; 84 | const revoke = this.tabMessageRevokes[request.id]; 85 | if (revoke && request.error) { 86 | revoke(new RemoteError(JSON.parse(request.error))); 87 | } else if (resolve) { 88 | resolve(request.message); 89 | } 90 | delete this.tabMessageResolves[request.id]; 91 | delete this.tabMessageRevokes[request.id]; 92 | 93 | this.tabPortPendingRequests[tabId] = this.tabPortPendingRequests[tabId] 94 | .filter(({ id }) => id !== request.id); 95 | if (this.tabPortPendingRequests[tabId].length === 0) { 96 | delete this.tabPortPendingRequests[tabId]; 97 | } 98 | }); 99 | 100 | // Handle any promise resolutions that are waiting for this port. 101 | if (this.tabPortResolves[tabId]) { 102 | this.tabPortResolves[tabId].forEach(resolve => resolve(port)); 103 | delete this.tabPortResolves[tabId]; 104 | } 105 | 106 | // Handle disconnects, this will happen on every page navigation. 107 | port.onDisconnect.addListener(async () => { 108 | if (this.tabPorts[tabId] === port) { 109 | delete this.tabPorts[tabId]; 110 | } 111 | 112 | // If there are pending requests, we'll need to resend them. The resolve/revoke callbacks will 113 | // still be in place, we just need to repost the requests. 114 | const pendingRequests = this.tabPortPendingRequests[tabId]; 115 | if (pendingRequests && pendingRequests.length) { 116 | const newPort = await this.getTabPort(tabId); 117 | pendingRequests.forEach(request => newPort.postMessage(request)); 118 | } 119 | }); 120 | }; 121 | 122 | broadcastConnectionStatus = () => { 123 | browser.runtime.sendMessage({ 124 | channel: 'connectionStatus', 125 | connectionStatus: this.connectionStatus, 126 | }); 127 | }; 128 | 129 | connect = async (url, sessionId = 'default') => { 130 | try { 131 | await this.client.connect(url, 'extension', sessionId); 132 | this.client.send(null, { channel: 'initialConnection' }); 133 | this.client.on('close', this.handleConnectionLoss); 134 | this.client.on('error', this.handleConnectionLoss); 135 | this.pingInterval = setInterval(() => { 136 | let alive = false; 137 | this.client.ping().then(() => { alive = true; }); 138 | setTimeout(() => { 139 | if (!alive) { 140 | this.handleConnectionLoss(); 141 | } 142 | }, 58000); 143 | }, 60000); 144 | } catch (error) { 145 | this.handleConnectionLoss(); 146 | this.connectionStatus = 'error'; 147 | this.broadcastConnectionStatus(); 148 | } 149 | }; 150 | 151 | connectOnLaunch = async () => { 152 | const { url, sessionId } = await this.findConnectionDetails(); 153 | // This will only apply if the browser was launched by the browser client. 154 | this.quitOnConnectionLoss = true; 155 | await this.connect(url, sessionId); 156 | }; 157 | 158 | findConnectionDetails = async () => (new Promise((resolve) => { 159 | const extractConnectionDetails = (tabId, changeInfo, tab) => { 160 | const url = tab ? tab.url : tabId; 161 | let match = /remoteBrowserSessionId=([^&]*)/.exec(url); 162 | const sessionId = match && match.length > 1 && match[1]; 163 | match = /remoteBrowserUrl=([^&]*)/.exec(url); 164 | const connectionUrl = match && match.length > 1 && match[1]; 165 | 166 | if (sessionId && connectionUrl) { 167 | resolve({ sessionId, url: connectionUrl }); 168 | browser.tabs.onUpdated.removeListener(extractConnectionDetails); 169 | browser.tabs.update({ url: 'about:blank' }); 170 | } 171 | }; 172 | browser.tabs.onUpdated.addListener(extractConnectionDetails); 173 | browser.tabs.getCurrent().then(extractConnectionDetails); 174 | })); 175 | 176 | getTabPort = async (tabId) => { 177 | const port = this.tabPorts[tabId]; 178 | if (port) { 179 | return port; 180 | } 181 | return new Promise((resolve) => { 182 | this.tabPortResolves[tabId] = this.tabPortResolves[tabId] || []; 183 | this.tabPortResolves[tabId].push(resolve); 184 | }); 185 | }; 186 | 187 | handleConnectionLoss = async () => { 188 | if (this.pingInterval) { 189 | clearInterval(this.pingInterval); 190 | } 191 | if (this.quitOnConnectionLoss) { 192 | this.quit(); 193 | } 194 | } 195 | 196 | sendToTab = async (tabId, message) => { 197 | const port = await this.getTabPort(tabId); 198 | this.tabMessageId += 1; 199 | const id = this.tabMessageId; 200 | return new Promise((resolve, revoke) => { 201 | const request = { id, message }; 202 | // Store this in case the port disconnects before we get a response. 203 | this.tabPortPendingRequests[tabId] = this.tabPortPendingRequests[tabId] || []; 204 | this.tabPortPendingRequests[tabId].push(request); 205 | 206 | this.tabMessageResolves[id] = resolve; 207 | this.tabMessageRevokes[id] = revoke; 208 | port.postMessage(request); 209 | }); 210 | }; 211 | 212 | quit = async () => ( 213 | Promise.all((await browser.windows.getAll()) 214 | .map(({ id }) => browser.windows.remove(id))) 215 | ); 216 | } 217 | 218 | 219 | (async () => { 220 | const background = new Background(); 221 | // TODO: This should be disabled in extension builds that are meant to be distributed 222 | // independently from the node module as a security measure. 223 | await background.connectOnLaunch(); 224 | })(); 225 | -------------------------------------------------------------------------------- /src/extension/content.js: -------------------------------------------------------------------------------- 1 | import { JSONfn } from '../common'; 2 | import { RemoteError } from '../errors'; 3 | 4 | 5 | window.JSONfn = JSONfn; 6 | 7 | let backgroundPort; 8 | 9 | const handleMessage = ({ id, message }) => { 10 | if (message.channel === 'evaluateInContent') { 11 | const { asyncFunction, args } = message; 12 | Promise.resolve() 13 | // eslint-disable-next-line no-eval 14 | .then(() => eval(`(${asyncFunction}).apply(null, window.JSONfn.parse(${JSON.stringify(window.JSONfn.stringify(args))}))`)) 15 | .then(result => backgroundPort.postMessage({ id, message: result })) 16 | .catch((error) => { 17 | backgroundPort.postMessage({ 18 | id, 19 | error: JSONfn.stringify((new RemoteError(error)).toJSON()), 20 | }); 21 | }); 22 | } 23 | }; 24 | 25 | const createNewConnection = () => { 26 | backgroundPort = browser.runtime.connect({ name: 'contentScriptConnection' }); 27 | backgroundPort.onDisconnect.addListener(createNewConnection); 28 | backgroundPort.onMessage.addListener(handleMessage); 29 | }; 30 | 31 | createNewConnection(); 32 | -------------------------------------------------------------------------------- /src/extension/img/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intoli/remote-browser/b8c2a9ed9a024b6024efbfc331b174d57fe58870/src/extension/img/icon-32x32.png -------------------------------------------------------------------------------- /src/extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "applications": { 4 | "gecko": { 5 | "id": "feverdream@intoli.com" 6 | } 7 | }, 8 | "browser_action": { 9 | "default_icon": { 10 | "32": "img/icon-32x32.png" 11 | }, 12 | "default_popup": "popup.html", 13 | "default_title": "Remote Browser" 14 | }, 15 | "content_scripts": [ 16 | { 17 | "matches": [ 18 | "*://*/*", 19 | "file:///*" 20 | ], 21 | "js": ["content.js"], 22 | "run_at": "document_start" 23 | } 24 | ], 25 | "background": { 26 | "scripts": ["background.js"] 27 | }, 28 | "permissions": [ 29 | "", 30 | "activeTab", 31 | "alarms", 32 | "background", 33 | "bookmarks", 34 | "browserSettings", 35 | "browsingData", 36 | "contentSettings", 37 | "contextMenus", 38 | "contextualIdentities", 39 | "cookies", 40 | "debugger", 41 | "dns", 42 | "downloads", 43 | "downloads.open", 44 | "find", 45 | "geolocation", 46 | "history", 47 | "identity", 48 | "idle", 49 | "management", 50 | "menus", 51 | "nativeMessaging", 52 | "notifications", 53 | "pageCapture", 54 | "pkcs11", 55 | "privacy", 56 | "proxy", 57 | "sessions", 58 | "storage", 59 | "tabHide", 60 | "tabs", 61 | "theme", 62 | "topSites", 63 | "webNavigation", 64 | "webRequest", 65 | "webRequestBlocking" 66 | ], 67 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'" 68 | } 69 | -------------------------------------------------------------------------------- /src/extension/popup.css: -------------------------------------------------------------------------------- 1 | #form-container { 2 | display: flex; 3 | } 4 | 5 | #status-container.connected .hide-connected, 6 | #status-container.disconnected .hide-disconnected, 7 | #status-container.error .hide-error { 8 | display: none; 9 | } 10 | 11 | #error-message { 12 | color: red; 13 | } 14 | 15 | p { 16 | width: 100%; 17 | } 18 | 19 | input { 20 | margin-right: 0.5em; 21 | } 22 | 23 | #host { 24 | flex: 3 0 2em; 25 | } 26 | 27 | #port { 28 | width: 6em; 29 | } 30 | 31 | #connect { 32 | flex: 0 0 auto; 33 | } 34 | 35 | #connected-message { 36 | white-space: nowrap; 37 | } 38 | 39 | #disconnect { 40 | display: block; 41 | margin: 0 auto; 42 | } 43 | -------------------------------------------------------------------------------- /src/extension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

Something went wrong. Please check the host and port, then try again.

9 |

Enter a host and port to connect to remote browser.

10 |
11 | 17 | 23 | 29 |
30 |

You are currently connected to a remote browser client!

31 |
32 | 38 |
39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/extension/popup.js: -------------------------------------------------------------------------------- 1 | const connectButton = document.getElementById('connect'); 2 | const disconnectButton = document.getElementById('disconnect'); 3 | const hostInput = document.getElementById('host'); 4 | const portInput = document.getElementById('port'); 5 | const statusContainerDiv = document.getElementById('status-container'); 6 | 7 | // Request the current connection status and handle updates from the background. 8 | browser.runtime.onMessage.addListener((request) => { 9 | if (request.channel === 'connectionStatus') { 10 | ['disconnected', 'connected', 'error'].forEach((status) => { 11 | statusContainerDiv.classList.remove(status); 12 | }); 13 | statusContainerDiv.classList.add(request.connectionStatus); 14 | } 15 | }); 16 | browser.runtime.sendMessage({ 17 | channel: 'connectionStatusRequest', 18 | }); 19 | 20 | 21 | connectButton.addEventListener('click', () => { 22 | const port = parseInt(portInput.value, 10); 23 | const url = `${hostInput.value}:${port}`; 24 | browser.runtime.sendMessage({ 25 | channel: 'connectionRequest', 26 | sessionId: 'default', 27 | url, 28 | }); 29 | }); 30 | 31 | 32 | disconnectButton.addEventListener('click', () => { 33 | browser.runtime.sendMessage({ 34 | channel: 'disconnectionRequest', 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Browser from './browser'; 2 | import ConnectionProxy from './connections/proxy'; 3 | 4 | export default Browser; 5 | export { Browser }; 6 | export { ConnectionProxy }; 7 | export * from './errors'; 8 | export * from './launchers'; 9 | -------------------------------------------------------------------------------- /src/launchers.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import chromedriver from 'chromedriver'; 4 | import geckodriver from 'geckodriver'; 5 | import { Builder } from 'selenium-webdriver'; 6 | import chrome from 'selenium-webdriver/chrome'; 7 | import { Command } from 'selenium-webdriver/lib/command'; 8 | import firefox from 'selenium-webdriver/firefox'; 9 | 10 | 11 | const extension = path && path.resolve && path.resolve(__dirname, 'extension'); 12 | 13 | 14 | const constructFileUrl = (connectionUrl, sessionId) => ( 15 | `file:///?remoteBrowserUrl=${connectionUrl}&remoteBrowserSessionId=${sessionId}` 16 | ); 17 | 18 | 19 | export const launchChrome = async (connectionUrl, sessionId = 'default') => { 20 | const driver = await new Builder() 21 | .forBrowser('chrome') 22 | .setChromeOptions(new chrome.Options() 23 | .addArguments([`--load-extension=${extension}`])) 24 | .setChromeService(new chrome.ServiceBuilder(chromedriver.path)) 25 | .build(); 26 | 27 | const fileUrl = constructFileUrl(connectionUrl, sessionId); 28 | await driver.get(fileUrl); 29 | 30 | return driver; 31 | }; 32 | 33 | 34 | export const launchFirefox = async (connectionUrl, sessionId = 'default') => { 35 | const driver = await new Builder() 36 | .forBrowser('firefox') 37 | .setFirefoxOptions(new firefox.Options() 38 | .headless()) 39 | .setFirefoxService(new firefox.ServiceBuilder(geckodriver.path)) 40 | .build(); 41 | 42 | const command = new Command('install addon') 43 | .setParameter('path', extension) 44 | .setParameter('temporary', true); 45 | await driver.execute(command); 46 | 47 | const fileUrl = constructFileUrl(connectionUrl, sessionId); 48 | await driver.get(fileUrl); 49 | 50 | return driver; 51 | }; 52 | -------------------------------------------------------------------------------- /test/data/blank-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Blank Page 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/data/red-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Red Page 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/test-browsers.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import path from 'path'; 3 | 4 | import express from 'express'; 5 | import Jimp from 'jimp'; 6 | import portfinder from 'portfinder'; 7 | 8 | // We're using the compiled code, so must register the source maps. 9 | import 'source-map-support/register' 10 | import Browser, { RemoteError } from '../dist'; 11 | 12 | 13 | ['Firefox', 'Chrome'].forEach((browserName) => { 14 | describe(`${browserName} Browser`, function() { 15 | this.timeout(15000); 16 | let app; 17 | let appServer; 18 | let browser; 19 | let urlPrefix; 20 | const blankPagePath = path.resolve(__dirname, 'data', 'blank-page.html'); 21 | const redPagePath = path.resolve(__dirname, 'data', 'red-page.html'); 22 | before(async () => { 23 | browser = new Browser(); 24 | await browser.launch(browserName.toLowerCase()); 25 | const port = await portfinder.getPortPromise(); 26 | urlPrefix = `http://localhost:${port}`; 27 | app = express(); 28 | app.use(express.static('/')); 29 | await new Promise((resolve) => { appServer = app.listen(port, resolve); }); 30 | }); 31 | after(async () => { 32 | await browser.quit() 33 | await new Promise(resolve => appServer.close(resolve)); 34 | }); 35 | 36 | it('should receive a ping/pong response', async () => { 37 | const response = await browser.client.ping(); 38 | assert.equal(response, 'pong'); 39 | }); 40 | 41 | it('should evaluate JavaScript in the background context', async () => { 42 | const userAgent = await browser.evaluateInBackground(async () => window.navigator.userAgent); 43 | assert(typeof userAgent === 'string'); 44 | assert(userAgent.includes(browserName)); 45 | }); 46 | 47 | it('should evaluate JavaScript in the content context', async () => { 48 | // Get the current tab ID. 49 | const tabId = (await browser.evaluateInBackground(async () => ( 50 | (await browser.tabs.query({ active: true })).map(tab => tab.id) 51 | )))[0]; 52 | assert.equal(typeof tabId, 'number'); 53 | 54 | // Navigate to the test page. 55 | const blankPageUrl = urlPrefix + blankPagePath; 56 | await browser.evaluateInBackground(async (tabId, blankPageUrl) => ( 57 | browser.tabs.update({ url: blankPageUrl }) 58 | ), tabId, blankPageUrl); 59 | 60 | 61 | // Retrieve the page title. 62 | const title = await browser.evaluateInContent(tabId, async () => ( 63 | new Promise((resolve) => { 64 | if (['complete', 'loaded'].includes(document.readyState)) { 65 | resolve(document.title); 66 | } else { 67 | document.addEventListener('DOMContentLoaded', () => resolve(document.title)); 68 | } 69 | }) 70 | )); 71 | assert.equal(title, 'Blank Page'); 72 | }); 73 | 74 | it('should evaluate JavaScript in the background context when called as a function', async () => { 75 | const userAgent = await browser(async (prefix) => ( 76 | prefix + window.navigator.userAgent 77 | ), 'USER-AGENT: '); 78 | assert(typeof userAgent === 'string'); 79 | assert(userAgent.includes(browserName)); 80 | assert(userAgent.startsWith('USER-AGENT: ')); 81 | }); 82 | 83 | it('should evaluate JavaScript in the content context when called as a function', async () => { 84 | // Get the current tab ID. 85 | const tabId = (await browser.evaluateInBackground(async () => ( 86 | (await browser.tabs.query({ active: true })).map(tab => tab.id) 87 | )))[0]; 88 | assert.equal(typeof tabId, 'number'); 89 | 90 | // Navigate to the test page. 91 | const blankPageUrl = urlPrefix + blankPagePath; 92 | await browser.evaluateInBackground(async (tabId, blankPageUrl) => ( 93 | browser.tabs.update({ url: blankPageUrl }) 94 | ), tabId, blankPageUrl); 95 | 96 | // Retrieve the page title. 97 | const title = await browser[tabId]( async () => ( 98 | new Promise((resolve) => { 99 | if (['complete', 'loaded'].includes(document.readyState)) { 100 | resolve(document.title); 101 | } else { 102 | document.addEventListener('DOMContentLoaded', () => resolve(document.title)); 103 | } 104 | }) 105 | )); 106 | assert.equal(title, 'Blank Page'); 107 | }); 108 | 109 | it('should raise a RemoteError if the background evaluation fails', async () => { 110 | try { 111 | await browser(() => { const variable = nonExistentVariableName; }); 112 | } catch (error) { 113 | assert(error instanceof RemoteError); 114 | const { remoteError } = error; 115 | assert.equal(remoteError.name, 'ReferenceError'); 116 | return; 117 | } 118 | throw new Error('The expected error was not thrown.'); 119 | }); 120 | 121 | it('should raise a RemoteError if the content evaluation fails', async () => { 122 | const tabId = (await browser.evaluateInBackground(async () => ( 123 | (await browser.tabs.query({ active: true })).map(tab => tab.id) 124 | )))[0]; 125 | try { 126 | await browser[tabId](() => { const variable = nonExistentVariableName; }); 127 | } catch (error) { 128 | assert(error instanceof RemoteError); 129 | const { remoteError } = error; 130 | assert.equal(remoteError.name, 'ReferenceError'); 131 | return; 132 | } 133 | throw new Error('The expected error was not thrown.'); 134 | }); 135 | 136 | it('should successfully transfer a screenshot of a remote page', async () => { 137 | // Get the current tab ID. 138 | const tabId = (await browser(async () => ( 139 | (await browser.tabs.query({ active: true })).map(tab => tab.id) 140 | )))[0]; 141 | assert.equal(typeof tabId, 'number'); 142 | 143 | // Navigate to the red test page. 144 | const redPageUrl = urlPrefix + redPagePath; 145 | await browser(async (tabId, redPageUrl) => ( 146 | browser.tabs.update({ url: redPageUrl }) 147 | ), tabId, redPageUrl); 148 | 149 | // Wait for the DOM to load. 150 | await browser.evaluateInContent(tabId, async () => ( 151 | new Promise((resolve) => { 152 | if (document.readyState === 'complete') { 153 | resolve(); 154 | } else { 155 | document.addEventListener('load', resolve); 156 | } 157 | }) 158 | )); 159 | 160 | // There's a bit of a race condition here, Chrome needs a few milliseconds to actually render. 161 | await new Promise(resolve => setTimeout(resolve, 100)); 162 | 163 | // Fetch a data URI of the image. 164 | const dataUri = await browser(async (tabId) => ( 165 | browser.tabs.captureVisibleTab({ format: 'png' }) 166 | ), tabId); 167 | 168 | // Extract the actual data as a buffer. 169 | const imageBuffer = new Buffer(dataUri.match(/^data:.+\/.+;base64,(.*)$/)[1], 'base64'); 170 | 171 | const image = await Jimp.read(imageBuffer); 172 | // We'll scan a 100x100 pixel square in the image and assert that it's red. 173 | image.scan(100, 100, 100, 100, (x, y, index) => { 174 | // Red, green, blue in sequence. 175 | assert.equal(image.bitmap.data[index], 255); 176 | assert.equal(image.bitmap.data[index + 1], 0); 177 | assert.equal(image.bitmap.data[index + 2], 0); 178 | }); 179 | }); 180 | 181 | it('should support direct proxying of the remote browser API', async () => { 182 | const blankPageUrl = urlPrefix + redPagePath; 183 | const { id } = await browser.tabs.update({ url: blankPageUrl }); 184 | const actualUrl = await browser[id](() => window.location.href); 185 | assert.equal(actualUrl, blankPageUrl); 186 | }); 187 | 188 | it('should allow waiting for `readyState` changes', async() => { 189 | const redPageUrl = urlPrefix + redPagePath; 190 | const { id } = await browser.tabs.update({ url: redPageUrl }); 191 | const readyState = await browser[id].readyState('complete'); 192 | assert.equal(readyState, 'complete'); 193 | }); 194 | 195 | it('should allow access to the `browser.webRequest` API', async () => { 196 | // This won't match any URLs, but the `addListener()` call should succeed. 197 | await browser.webRequest.onBeforeRequest.addListener(() => {}, { urls: ['https://into.li'] }); 198 | }); 199 | }); 200 | }); 201 | 202 | 203 | describe('Remote Tour Browser', function() { 204 | this.timeout(15000); 205 | 206 | let browser; 207 | before(async () => { 208 | browser = new Browser(); 209 | await browser.launch('remote'); 210 | }); 211 | after(async () => { 212 | await browser.quit() 213 | }); 214 | 215 | it('should receive a ping/pong response', async () => { 216 | const response = await browser.client.ping(); 217 | assert.equal(response, 'pong'); 218 | }); 219 | 220 | it('should evaluate JavaScript in the background context', async () => { 221 | const userAgent = await browser.evaluateInBackground(async () => window.navigator.userAgent); 222 | assert(typeof userAgent === 'string'); 223 | assert(userAgent.includes('Firefox')); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /test/test-common.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | import * as errors from '../src/errors'; 4 | import * as common from '../src/common'; 5 | 6 | const catchError = async(promise) => { 7 | try { 8 | await promise; 9 | } catch (e) { 10 | return e; 11 | } 12 | }; 13 | 14 | describe('common', () => { 15 | describe('serializeFunction', () => { 16 | it('should serialize arrow functions', () => { 17 | const sayHello = () => 'hello'; 18 | const serialized = common.serializeFunction(sayHello); 19 | const result = eval(`(${serialized})()`); 20 | assert.equal(result, 'hello'); 21 | }); 22 | 23 | it('should handle broken Safari functions', async () => { 24 | const sayHello = 'async function () => \'hello\''; 25 | const serialized = common.serializeFunction(sayHello); 26 | const result = await eval(`(${serialized})()`); 27 | assert.equal(result, 'hello'); 28 | }); 29 | }); 30 | 31 | describe('TimeoutPromise', () => { 32 | it('should throw an exception after timeout', async () => { 33 | const error = await catchError(new common.TimeoutPromise(() => null, 50)) 34 | assert(error instanceof errors.TimeoutError); 35 | }); 36 | 37 | it('should resolve with the correct value', async () => { 38 | const expectedValue = 'hello'; 39 | const value = await new common.TimeoutPromise(resolve => resolve(expectedValue), 50); 40 | assert.equal(value, expectedValue); 41 | }); 42 | 43 | it('should revoke with the correct error message', async () => { 44 | const expectedMessage = 'hello'; 45 | const error = await catchError( 46 | new common.TimeoutPromise((resolve, revoke) => revoke(new Error(expectedMessage)), 50), 47 | ); 48 | assert(error instanceof Error); 49 | assert.equal(error.message, expectedMessage); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/test-connections.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | import { Client, ConnectionProxy, Server } from '../src/connections'; 4 | import { TimeoutError } from '../src/errors'; 5 | 6 | 7 | const createProxiedConnection = async (sessionId = 'default') => { 8 | const proxy = new ConnectionProxy(); 9 | const port = await proxy.listen(); 10 | const clients = [new Client(), new Client()] 11 | const url = `http://localhost:${port}/`; 12 | await clients[0].connect(url, 'user', sessionId); 13 | await clients[1].connect(url, 'extension', sessionId); 14 | 15 | return { clients, proxy }; 16 | }; 17 | 18 | 19 | describe('Proxied Connections', async () => { 20 | it('should echo messages between clients', async () => { 21 | const { clients } = await createProxiedConnection(); 22 | const sent = 'hello'; 23 | clients[1].subscribe(async (echoed) => echoed); 24 | let received = await clients[0].send(sent); 25 | assert.equal(sent, received); 26 | clients[0].subscribe(async (echoed) => echoed); 27 | received = await clients[1].send(sent); 28 | assert.equal(sent, received); 29 | }); 30 | 31 | it('should handle pings from both clients', async () => { 32 | const { clients } = await createProxiedConnection(); 33 | 34 | assert.equal(await clients[0].ping(), 'pong'); 35 | assert.equal(await clients[1].ping(), 'pong'); 36 | }); 37 | 38 | it('should handle multiple channels', async () => { 39 | const channelCount = 5; 40 | const messageCount = 100; 41 | 42 | const { clients } = await createProxiedConnection(); 43 | 44 | const channels = []; 45 | for (let i = 0; i < channelCount; i++) { 46 | const channel = `channel-${i}`; 47 | clients[0].subscribe(async (data) => ({ channel, data }), { channel }); 48 | channels.push(channel); 49 | } 50 | 51 | let i = 0; 52 | const expectedMessages = []; 53 | const promises = [] 54 | for (let i = 0; i < messageCount; i++) { 55 | const channel = channels[i % channelCount]; 56 | expectedMessages.push({ channel, data: i }); 57 | promises.push(clients[1].send(i, { channel })); 58 | } 59 | const messages = await Promise.all(promises); 60 | 61 | assert.deepEqual(messages, expectedMessages); 62 | }); 63 | 64 | it('should raise a timeout error waiting for a response', async () => { 65 | const { clients } = await createProxiedConnection(); 66 | 67 | clients[0].subscribe(async () => new Promise(() => {})); 68 | let error; 69 | try { 70 | await clients[1].send(null, { timeout: 10 }); 71 | } catch (e) { 72 | error = e; 73 | } 74 | assert(error instanceof TimeoutError); 75 | }); 76 | 77 | it('should handle connections with multiple session IDs', async () => { 78 | const connectionCount = 5; 79 | 80 | const unsubscribedClients = [] 81 | for (let i = 0; i < connectionCount; i++) { 82 | const sessionId = `session-${i}`; 83 | const { clients } = await createProxiedConnection(sessionId); 84 | 85 | clients[1].subscribe(async (data) => ({ sessionId, data })); 86 | unsubscribedClients.push(clients[0]) 87 | } 88 | 89 | const promises = unsubscribedClients.map((client, index) => ( 90 | client.send(`some-data-${index}`) 91 | )); 92 | 93 | const results = await Promise.all(promises); 94 | results.forEach(({ data, sessionId }, index) => { 95 | assert.equal(unsubscribedClients[index].sessionId, sessionId); 96 | assert.equal(data, `some-data-${index}`); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/test-language-support.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | 4 | describe('Language Support', () => { 5 | it('should run and pass this test', () => { 6 | assert(true); 7 | }); 8 | it('should support the object spread operator', () => { 9 | const a = { a: 1, b: 2 }; 10 | const b = { ...a, a: 2 }; 11 | assert.deepEqual(b, { a: 2, b: 2 }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /webpack/client.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const webpack = require('webpack'); 4 | 5 | 6 | const options = { 7 | context: path.resolve(__dirname, '..'), 8 | entry: { 9 | index: path.resolve(__dirname, '..', 'src', 'index.js'), 10 | }, 11 | output: { 12 | path: path.resolve(__dirname, '..', 'dist'), 13 | filename: '[name].js', 14 | library: 'Browser', 15 | libraryTarget: 'umd', 16 | umdNamedDefine: true, 17 | }, 18 | externals: [ 19 | 'express', 20 | 'chromedriver', 21 | 'geckodriver', 22 | 'ws', 23 | ], 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.js$/, 28 | include: path.resolve(__dirname, '..', 'src'), 29 | enforce: 'pre', 30 | loader: 'eslint-loader', 31 | }, 32 | { 33 | test: /\.js$/, 34 | include: path.resolve(__dirname, '..', 'src'), 35 | loader: 'babel-loader', 36 | }, 37 | ], 38 | }, 39 | plugins: [ 40 | new webpack.DefinePlugin({ 41 | 'typeof window': '"undefined"', 42 | }), 43 | ], 44 | target: 'node', 45 | devtool: 'source-map', 46 | node: { 47 | __dirname: false, 48 | } 49 | }; 50 | 51 | 52 | module.exports = options; 53 | -------------------------------------------------------------------------------- /webpack/extension.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const ChromeExtensionReloader = require('webpack-chrome-extension-reloader'); 4 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 5 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 6 | const webpack = require('webpack'); 7 | 8 | const package = require('../package.json'); 9 | 10 | 11 | const options = { 12 | entry: { 13 | 'background': path.resolve(__dirname, '..', 'src', 'extension', 'background.js'), 14 | 'content': path.resolve(__dirname, '..', 'src', 'extension', 'content.js'), 15 | 'popup': path.resolve(__dirname, '..', 'src', 'extension', 'popup.js'), 16 | }, 17 | output: { 18 | path: path.resolve(__dirname, '..', 'dist', 'extension'), 19 | filename: '[name].js' 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.js$/, 25 | include: path.resolve(__dirname, '..', 'src'), 26 | enforce: 'pre', 27 | loader: 'eslint-loader', 28 | }, 29 | { 30 | test: /\.js$/, 31 | include: path.resolve(__dirname, '..', 'src'), 32 | loader: 'babel-loader', 33 | }, 34 | { 35 | // This bypasses improper namespacing in the polyfill guard. 36 | // See: https://github.com/mozilla/webextension-polyfill/issues/68 37 | test: require.resolve('webextension-polyfill'), 38 | use: 'imports-loader?browser=>undefined', 39 | } 40 | ], 41 | }, 42 | plugins: [ 43 | new CleanWebpackPlugin( 44 | [path.join('dist', 'extension')], 45 | { 46 | root: path.resolve(__dirname, '..'), 47 | }, 48 | ), 49 | new CopyWebpackPlugin([{ 50 | from: path.resolve(__dirname, '..', 'src', 'extension', 'manifest.json'), 51 | to: path.join('manifest.json'), 52 | transform: (manifest) => { 53 | return JSON.stringify({ 54 | description: package.description, 55 | name: package.name, 56 | version: package.version, 57 | ...JSON.parse(manifest), 58 | }, null, 2) 59 | }, 60 | }]), 61 | new CopyWebpackPlugin([{ 62 | from: path.resolve(__dirname, '..', 'src', 'extension', '*.html'), 63 | to: '[name].[ext]', 64 | toType: 'template', 65 | }]), 66 | new CopyWebpackPlugin([{ 67 | from: path.resolve(__dirname, '..', 'src', 'extension', '*.css'), 68 | to: '[name].[ext]', 69 | toType: 'template', 70 | }]), 71 | new CopyWebpackPlugin([{ 72 | from: path.resolve(__dirname, '..', 'src', 'extension', 'img', '*.png'), 73 | to: path.join('img', '[name].[ext]'), 74 | toType: 'template', 75 | }]), 76 | new webpack.DefinePlugin({ 77 | 'typeof window': '"object"', 78 | }), 79 | new webpack.ProvidePlugin({ 80 | browser: 'webextension-polyfill', 81 | }), 82 | ], 83 | target: 'web', 84 | devtool: 'source-map', 85 | node: { 86 | fs: 'empty', 87 | net: 'empty', 88 | }, 89 | }; 90 | 91 | 92 | if (process.env.NODE_ENV === 'development') { 93 | options.plugins.push(new ChromeExtensionReloader({ 94 | entries: { 95 | background: 'background', 96 | contentScript: 'content', 97 | }, 98 | })); 99 | } 100 | 101 | 102 | module.exports = options; 103 | -------------------------------------------------------------------------------- /webpack/index.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | require('./extension.config'), 3 | require('./client.config'), 4 | ]; 5 | -------------------------------------------------------------------------------- /webpack/web-client.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const webpack = require('webpack'); 4 | 5 | 6 | // We'll explicitly whitelist the dependencies that we actually want to include. 7 | const packageJson = require(path.resolve(__dirname, '..', 'package.json')); 8 | const whitelistedDependencies = ['isomorphic-fetch', 'simple-websocket']; 9 | const blacklistedDependencies = Object.keys(packageJson.dependencies) 10 | .filter(packageName => !whitelistedDependencies.includes(packageName)); 11 | 12 | 13 | const options = { 14 | entry: { 15 | index: path.resolve(__dirname, '..', 'src', 'index.js'), 16 | }, 17 | output: { 18 | path: path.resolve(__dirname, '..', 'dist'), 19 | filename: path.join('web-client', '[name].js'), 20 | library: 'Browser', 21 | libraryTarget: 'umd', 22 | umdNamedDefine: true, 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.js$/, 28 | include: path.resolve(__dirname, '..', 'src'), 29 | enforce: 'pre', 30 | loader: 'eslint-loader', 31 | }, 32 | { 33 | test: /\.js$/, 34 | include: path.resolve(__dirname, '..', 'src'), 35 | loader: 'babel-loader', 36 | }, 37 | ...blacklistedDependencies.map(dependency => ({ 38 | test: new RegExp(`^${path.resolve(__dirname, '..', 'node_modules', dependency)}/`), 39 | loader: 'null-loader', 40 | })), 41 | ], 42 | }, 43 | plugins: [ 44 | new webpack.DefinePlugin({ 45 | 'typeof window': '"object"', 46 | }), 47 | ], 48 | target: 'web', 49 | devtool: 'source-map', 50 | node: { 51 | __dirname: false, 52 | child_process: 'empty', 53 | fs: 'empty', 54 | net: 'empty', 55 | path: 'empty', 56 | }, 57 | }; 58 | 59 | 60 | module.exports = options; 61 | --------------------------------------------------------------------------------