├── .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 |
5 |
6 |
8 |
9 |
11 |
12 |
14 |
15 |
16 |
17 |
18 |
20 |
21 |
23 |
24 |
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 | 
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 |
27 | Connect
28 |
29 |
30 |
You are currently connected to a remote browser client!
31 |
32 |
36 | Disconnect
37 |
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 |
--------------------------------------------------------------------------------