├── .gitignore ├── fixtures ├── static │ ├── base2.html │ └── base.html ├── run_server.js └── server.js ├── test ├── custom_agent_module.js ├── subtitle_test.js ├── custom_agent_module_test.js ├── base-chrome-pool-proxy-test.js ├── base-chrome-pool-no-reuse-test.js ├── base-proxy-test.js ├── chrome-pool-navigate-test.js ├── chrome-pool-screen-recording-test.js ├── chrome-pool-extra-headers-test.js ├── chrome-pool-script-test.js └── chrome-pool-cookies-test.js ├── .eslintrc.js ├── examples ├── example.json ├── screen_recorder.js ├── add_script.js └── screen_recorder.py ├── .travis.yml ├── LICENSE.txt ├── package.json ├── scripts └── run-tests.sh ├── index.js ├── bin └── chromedriver-proxy ├── lib ├── chrome_agent_slave.js ├── chrome_agent_manager.js ├── chromedriver.js ├── chrome_pool.js ├── chrome_agent.js ├── realtime_screen_recorder.js ├── proxy.js └── screen_recorder.js ├── clients ├── py │ └── chrome_driver_proxy.py └── js │ └── chrome_driver_proxy.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | -------------------------------------------------------------------------------- /fixtures/static/base2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | base 2 page 4 | 5 | 6 |

base 2 page

7 | 8 | 9 | -------------------------------------------------------------------------------- /fixtures/static/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | base page 4 | 5 | 6 |

base page

7 | second page 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/custom_agent_module.js: -------------------------------------------------------------------------------- 1 | 2 | const ChromeAgent = require('../lib/chrome_agent.js') 3 | 4 | class CustomAgent extends ChromeAgent { 5 | constructor(options) { 6 | super(options); 7 | } 8 | } 9 | 10 | module.exports = CustomAgent; 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "airbnb-base", 3 | rules: { 4 | "indent": ["error", 2], 5 | "linebreak-style": ["error", "unix"], 6 | "global-require": ["warn"], 7 | "import/no-dynamic-require": ["warn"], 8 | "no-console": ["warn"], 9 | } 10 | }; -------------------------------------------------------------------------------- /examples/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "tmpDir": "/tmp", 3 | "proxy": { 4 | "port": 4444, 5 | "baseUrl": "/wd/hub" 6 | }, 7 | "chromedriver": { 8 | "chromedriverPath": "/home/jgowan/bin/chromedriver", 9 | "port": 4445, 10 | "autoRestart": true 11 | }, 12 | "chromePool": { 13 | "enable": true, 14 | "chromePath": "/usr/bin/google-chrome", 15 | "reuse": true, 16 | "chromeAgent": { 17 | "screenRecorder": { 18 | "videoFormat": "mp4" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/screen_recorder.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 4 | const Driver = require('../clients/js/chrome_driver_proxy'); 5 | 6 | const chromeOptions = new ChromeOptions(); 7 | chromeOptions.addArguments( 8 | '--headless', 9 | '--disable-gpu', 10 | '--no-first-run', 11 | '--no-sandbox', 12 | ); 13 | const options = chromeOptions.toCapabilities(); 14 | 15 | const driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', options); 16 | driver.startScreencast({ params: { format: 'jpeg', quality: 80, everyNthFrame: 2 } }).then(() => driver.get('https://www.ziprecruiter.com')).then(() => driver.sleep(2)).then(() => driver.get('https://www.ziprecruiter.com/candidate/search?search=accountant&location=')) 17 | .then(() => driver.sleep(1)) 18 | .then(() => driver.stopScreencast()) 19 | .then(() => driver.getScreencastPath()) 20 | .then((result) => { 21 | console.log(result); 22 | return driver.quit(); 23 | }); 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | env: 3 | - CHROMEDRIVER_BIN=/tmp/chromedriver 4 | matrix: 5 | include: 6 | - node_js: "8" 7 | dist: trusty 8 | sudo: false 9 | cache: 10 | directories: 11 | - node_modules 12 | - chrome 13 | before_script: 14 | - curl https://chromedriver.storage.googleapis.com/2.42/chromedriver_linux64.zip > /tmp/chromedriver_linux64.zip 15 | - unzip -d /tmp /tmp/chromedriver_linux64.zip 16 | - /tmp/chromedriver --version 17 | - google-chrome-beta --version 18 | - sudo rm /usr/bin/google-chrome 19 | - sudo ln -s /usr/bin/google-chrome-beta /usr/bin/google-chrome 20 | - google-chrome --version 21 | - npm list 22 | - wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-64bit-static.tar.xz -O /tmp/ffmpeg-release-64bit-static.tar.xz 23 | - tar -xJf /tmp/ffmpeg-release-64bit-static.tar.xz -C /tmp 24 | - /tmp/ffmpeg-4.0.2-64bit-static/ffmpeg -version 25 | - sudo mv /tmp/ffmpeg-4.0.2-64bit-static/ffmpeg /usr/bin/ffmpeg 26 | addons: 27 | chrome: beta 28 | apt: 29 | packages: 30 | - xz-utils 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 ZipRecruiter 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chromedriver-proxy", 3 | "version": "1.0.6", 4 | "description": "An extensible proxy to chromedriver", 5 | "keywords": [ 6 | "selenium", 7 | "chrome", 8 | "chromedriver", 9 | "headless" 10 | ], 11 | "engines": { 12 | "node": ">=8 <11" 13 | }, 14 | "author": "Jason Gowan", 15 | "license": "MIT", 16 | "files": [ 17 | "README.md", 18 | "LICENSE.txt", 19 | "index.js", 20 | "package.json", 21 | "package-lock.json", 22 | "bin/", 23 | "lib/" 24 | ], 25 | "dependencies": { 26 | "aws-sdk": "^2.1287.0", 27 | "chrome-remote-interface": "*", 28 | "commander": "^2.20.3", 29 | "debug": "^3.2.7", 30 | "get-port": "^3.2.0", 31 | "http-proxy": "^1.18.1", 32 | "yarn": "^1.22.18" 33 | }, 34 | "devDependencies": { 35 | "chai": "^4.3.7", 36 | "js-yaml": "^3.14.1", 37 | "mocha": "^9.2.2", 38 | "node-simple-router": "^0.10.2", 39 | "pkg.json": "*", 40 | "selenium-webdriver": "~4.1.0" 41 | }, 42 | "scripts": { 43 | "test": "./scripts/run-tests.sh" 44 | }, 45 | "bin": { 46 | "chromedriver-proxy": "./bin/chromedriver-proxy" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /fixtures/run_server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | **/ 22 | 23 | 'use strict' 24 | 25 | require('./server')(8080) 26 | -------------------------------------------------------------------------------- /scripts/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright (c) 2017 ZipRecruiter 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | export DEBUG=chromedriver_proxy:* 24 | 25 | fuser -k 8080/tcp 26 | 27 | (node fixtures/run_server.js &) && mocha --exit --timeout 10000 && fuser -k 8080/tcp 28 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const HttpServer = require('./lib/proxy'); 26 | const ChromePool = require('./lib/chrome_pool'); 27 | const ChromeAgent = require('./lib/chrome_agent'); 28 | const ScreenRecorder = require('./lib/screen_recorder'); 29 | 30 | module.exports.HttpServer = HttpServer; 31 | module.exports.ChromePool = ChromePool; 32 | module.exports.ScreenRecorder = ScreenRecorder; 33 | module.exports.ChromeAgent = ChromeAgent; 34 | -------------------------------------------------------------------------------- /bin/chromedriver-proxy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const program = require('commander'); 26 | const { HttpServer } = require('..'); 27 | const fs = require('fs'); 28 | 29 | program 30 | .version('1.0.6') 31 | .option('--config [value]', 'json config file') 32 | .parse(process.argv); 33 | 34 | let config = {}; 35 | 36 | if (program.config) { 37 | config = JSON.parse(fs.readFileSync(program.config)); 38 | } 39 | 40 | const s = new HttpServer(config.proxy); 41 | s.start(config); 42 | process.on('SIGINT', () => { 43 | s.stop(); 44 | process.exit(); 45 | }); 46 | -------------------------------------------------------------------------------- /examples/add_script.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 25 | const Driver = require('../clients/js/chrome_driver_proxy'); 26 | 27 | const chromeOptions = new ChromeOptions(); 28 | chromeOptions.addArguments( 29 | // '--headless', 30 | // '--disable-gpu', 31 | '--disable-xss-auditor', 32 | '--no-first-run', 33 | '--no-sandbox', 34 | ); 35 | const options = chromeOptions.toCapabilities(); 36 | 37 | const driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', options); 38 | const myScript = 'console.log("inject script");' 39 | 40 | driver.addScript(myScript).then((result) => { 41 | driver.get('https://google.com'); 42 | }).then(() => driver.sleep(10000)) 43 | .then(() => { 44 | return driver.quit(); 45 | }).catch(err => console.log(err) ); 46 | -------------------------------------------------------------------------------- /test/subtitle_test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const fs = require('fs'); 28 | 29 | const ScreenRecorder = require('..').ScreenRecorder; 30 | 31 | describe('Create subtitle blob', () => { 32 | 33 | it('can create subtitles', (done) => { 34 | const recorder = new ScreenRecorder({}); 35 | 36 | recorder.firstFrameTimestamp = 100; 37 | recorder.currentDuration = 0; 38 | recorder.addSubtitle({text: 'first subtitle'}); 39 | recorder.currentDuration = 0; 40 | recorder.addSubtitle({text: 'first subtitle'}); 41 | recorder.currentDuration = 102; 42 | recorder.addSubtitle({text: 'second subtitle'}); 43 | recorder.currentDuration = 105; 44 | 45 | const subtitles = recorder.createSubtitle(); 46 | 47 | const expected = 'WEBVTT\n\n00:00.000 --> 01:42.000\nfirst subtitle\n\n01:42.000 --> 02:15.000\nsecond subtitle\n\n'; 48 | 49 | expect(subtitles.blob).to.be.equal(expected); 50 | 51 | done(); 52 | 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /lib/chrome_agent_slave.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const process = require('process'); 26 | const debug = require('debug')('chromedriver_proxy:chrome_agent_slave'); 27 | const path = require('path'); 28 | 29 | const options = JSON.parse(process.argv[2]); 30 | 31 | if (require.main === module) { 32 | debug('successfully forked chrome agent'); 33 | try { 34 | const ChromeAgent = require(path.resolve(options.chromeAgentModule)); 35 | const chromeAgent = new ChromeAgent(options); 36 | process.on('message', (msg) => { 37 | const blob = JSON.parse(msg); 38 | chromeAgent.handle(blob).then((result) => { 39 | process.send(JSON.stringify({ action: 'req', result })); 40 | }).catch((err) => { 41 | process.send(JSON.stringify({ action: 'req', result: { error: err.stack } })); 42 | }); 43 | }); 44 | process.send(JSON.stringify({ action: 'init', result: {} })); 45 | } catch (err) { 46 | debug(err); 47 | process.send(JSON.stringify({ action: 'error', result: { error: err } })); 48 | process.send(JSON.stringify({ action: 'init', result: { error: err } })); 49 | process.exit(1); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/screen_recorder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2017 ZipRecruiter 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | from __future__ import absolute_import 24 | from __future__ import print_function 25 | from selenium import webdriver as wd 26 | import sys 27 | import os 28 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../clients/py'))) 29 | import time 30 | from chrome_driver_proxy import ChromeDriverProxy 31 | 32 | chrome_options = wd.ChromeOptions() 33 | chrome_options.add_argument('--headless') 34 | chrome_options.add_argument('--disable-gpu') 35 | chrome_options.add_argument('--no-sandbox') 36 | chrome_options.add_argument('--no-first-run') 37 | chrome_options.add_argument('--window-size=1680,1050') 38 | 39 | capabilities = wd.DesiredCapabilities.CHROME.copy() 40 | capabilities.update(chrome_options.to_capabilities()) 41 | 42 | driver = ChromeDriverProxy( 43 | command_executor='http://127.0.0.1:4444/wd/hub', 44 | desired_capabilities=capabilities, 45 | keep_alive=True) 46 | 47 | driver.start_screencast(params=dict(format='jpeg', quality=80, everyNthFrame=2)) 48 | driver.get('https://www.ziprecruiter.com') 49 | time.sleep(2) 50 | driver.get('https://www.ziprecruiter.com/candidate/search?search=accountant&location=') 51 | time.sleep(1) 52 | driver.stop_screencast() 53 | path = driver.get_screencast_path() 54 | 55 | print(path) 56 | driver.quit() 57 | -------------------------------------------------------------------------------- /fixtures/server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | **/ 22 | 23 | 'use strict' 24 | 25 | const http = require('http') 26 | const Router = require('node-simple-router') 27 | const url = require('url') 28 | const fs = require('fs') 29 | 30 | function parseCookies (request) { 31 | // https://stackoverflow.com/a/3409200 32 | const list = {}, 33 | rc = request.headers.cookie; 34 | 35 | rc && rc.split(';').forEach(function( cookie ) { 36 | const parts = cookie.split('='); 37 | list[parts.shift().trim()] = decodeURI(parts.join('=')); 38 | }); 39 | 40 | return list; 41 | } 42 | 43 | module.exports = function (port, callback) { 44 | const router = new Router({static_route: __dirname + '/static'}) 45 | 46 | router.get('/cookies', function(req, res) { 47 | const cookies = parseCookies(req) 48 | let content = [] 49 | for (let name in cookies) { 50 | content.push(`

${name}${cookies[name]}

`) 51 | } 52 | res.write(content.join('')) 53 | res.end() 54 | }) 55 | router.get('/headers', function(req, res) { 56 | const headers = req.headers 57 | let content = [] 58 | for (let name in headers) { 59 | content.push(`

${name}: ${headers[name]}

`) 60 | } 61 | res.write(content.join('')) 62 | res.end() 63 | }) 64 | const server = http.createServer(router) 65 | server.listen(port, callback) 66 | return server 67 | } 68 | -------------------------------------------------------------------------------- /test/custom_agent_module_test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const HttpServer = require('..').HttpServer; 28 | 29 | const Driver = require('../clients/js/chrome_driver_proxy'); 30 | const Chrome = require('selenium-webdriver/chrome').Driver; 31 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 32 | 33 | const chromedriverBin = process.env.CHROMEDRIVER_BIN || '/usr/bin/chromedriver'; 34 | 35 | describe('Proxy with chrome pool enabled', () => { 36 | let server; 37 | let driver; 38 | const mockServerUrl = 'http://127.0.0.1:8080'; 39 | 40 | before(function (done) { 41 | const config = { 42 | proxy: { 43 | port: 4444, 44 | baseUrl: '/wd/hub', 45 | }, 46 | chromedriver: { 47 | chromedriverPath: chromedriverBin, 48 | port: 4445, 49 | autoRestart: false, 50 | }, 51 | chromePool: { 52 | enable: true, 53 | reuse: true, 54 | chromePath: '/usr/bin/google-chrome', 55 | chromeAgentModule: 'test/custom_agent_module.js', 56 | }, 57 | }; 58 | server = new HttpServer(config.proxy); 59 | this.timeout(5000); 60 | server.start(config, done); 61 | }); 62 | 63 | after((done) => { 64 | server.stop(done); 65 | }); 66 | 67 | beforeEach(() => { 68 | const chromeOptions = new ChromeOptions(); 69 | chromeOptions.addArguments( 70 | 'headless', 71 | 'disable-gpu', 72 | 'no-first-run', 73 | 'no-sandbox', 74 | ); 75 | 76 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 77 | }); 78 | 79 | afterEach((done) => { 80 | driver.quit().then(() => { done(); }); 81 | }); 82 | 83 | it('can run basic selenium test on custom agent', (done) => { 84 | driver.get(`${mockServerUrl}/base.html`).then(() => { done(); }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /lib/chrome_agent_manager.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Copyright (c) 2017 ZipRecruiter 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy 6 | * of this software and associated documentation files (the "Software"), to deal 7 | * in the Software without restriction, including without limitation the rights 8 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | * copies of the Software, and to permit persons to whom the Software is 10 | * furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in all 13 | * copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | * SOFTWARE. 22 | * */ 23 | 24 | const childProcess = require('child_process'); 25 | const path = require('path'); 26 | const debug = require('debug')('chromedriver_proxy:chrome_agent_manager'); 27 | const EventEmitter = require('events'); 28 | 29 | class ChromeAgentManager extends EventEmitter { 30 | start(options) { 31 | const self = this; 32 | const JsonOptions = JSON.stringify(options); 33 | debug(`fork agent with options: ${JsonOptions}`); 34 | self.child = childProcess.fork(`${__dirname}${path.sep}chrome_agent_slave.js`, [JsonOptions]); 35 | self.child.on('error', () => { 36 | debug('chrome agent has exited'); 37 | }); 38 | self.child.on('exit', (status) => { 39 | debug(`chrome agent has exit code: ${status}`); 40 | self.emit('exit', status); 41 | }); 42 | self.child.on('disconnect', () => { 43 | debug('chrome agent has disconnected'); 44 | }); 45 | 46 | self.child.on('message', (msg) => { 47 | const blob = JSON.parse(msg); 48 | self.emit(blob.action, blob.result); 49 | }); 50 | } 51 | 52 | send(options) { 53 | const self = this; 54 | return new Promise((resolve, reject) => { 55 | const exitListener = function exitListener() { 56 | reject(); 57 | }; 58 | self.on('exit', exitListener); 59 | self.once(options.action, (result) => { 60 | self.removeListener('exit', exitListener); 61 | if (typeof result === 'undefined') { 62 | reject(new Error('Unknown error')); 63 | } 64 | if ('error' in result) { 65 | reject(new Error(result.error)); 66 | } else { 67 | resolve(result); 68 | } 69 | }); 70 | self.child.send(JSON.stringify(options)); 71 | }); 72 | } 73 | 74 | stop() { 75 | const self = this; 76 | return self.send({ action: 'stop' }).catch(() => {}); 77 | } 78 | } 79 | 80 | module.exports = ChromeAgentManager; 81 | -------------------------------------------------------------------------------- /test/base-chrome-pool-proxy-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const HttpServer = require('..').HttpServer; 28 | 29 | const Driver = require('../clients/js/chrome_driver_proxy'); 30 | const Chrome = require('selenium-webdriver/chrome').Driver; 31 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 32 | 33 | const chromedriverBin = process.env.CHROMEDRIVER_BIN || '/usr/bin/chromedriver'; 34 | 35 | describe('Proxy with chrome pool enabled', () => { 36 | let server; 37 | let driver; 38 | const mockServerUrl = 'http://127.0.0.1:8080'; 39 | 40 | before(function (done) { 41 | const config = { 42 | proxy: { 43 | port: 4444, 44 | baseUrl: '/wd/hub', 45 | }, 46 | chromedriver: { 47 | chromedriverPath: chromedriverBin, 48 | port: 4445, 49 | autoRestart: false, 50 | }, 51 | chromePool: { 52 | enable: true, 53 | reuse: true, 54 | chromePath: '/usr/bin/google-chrome', 55 | }, 56 | }; 57 | server = new HttpServer(config.proxy); 58 | this.timeout(5000); 59 | server.start(config, done); 60 | }); 61 | 62 | after((done) => { 63 | server.stop(done); 64 | }); 65 | 66 | beforeEach(() => { 67 | const chromeOptions = new ChromeOptions(); 68 | chromeOptions.addArguments( 69 | 'headless', 70 | 'disable-gpu', 71 | 'no-first-run', 72 | 'no-sandbox', 73 | ); 74 | 75 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 76 | }); 77 | 78 | afterEach((done) => { 79 | driver.quit().then(() => { done(); }); 80 | }); 81 | 82 | it('can run basic selenium test', (done) => { 83 | driver.get(`${mockServerUrl}/base.html`).then(() => { done(); }); 84 | }); 85 | 86 | it('can run another selenium test', (done) => { 87 | driver.get(`${mockServerUrl}/base.html`).then(() => { done(); }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/base-chrome-pool-no-reuse-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const HttpServer = require('..').HttpServer; 28 | 29 | const Driver = require('../clients/js/chrome_driver_proxy'); 30 | const Chrome = require('selenium-webdriver/chrome').Driver; 31 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 32 | 33 | const chromedriverBin = process.env.CHROMEDRIVER_BIN || '/usr/bin/chromedriver'; 34 | 35 | describe('Proxy with chrome pool enabled no reuse', () => { 36 | let server; 37 | let driver; 38 | const mockServerUrl = 'http://127.0.0.1:8080'; 39 | 40 | before(function (done) { 41 | const config = { 42 | proxy: { 43 | port: 4444, 44 | baseUrl: '/wd/hub', 45 | }, 46 | chromedriver: { 47 | chromedriverPath: chromedriverBin, 48 | port: 4445, 49 | autoRestart: false, 50 | }, 51 | chromePool: { 52 | enable: true, 53 | reuse: false, 54 | chromePath: '/usr/bin/google-chrome', 55 | }, 56 | }; 57 | server = new HttpServer(config.proxy); 58 | this.timeout(5000); 59 | server.start(config, done); 60 | }); 61 | 62 | after((done) => { 63 | server.stop(done); 64 | }); 65 | 66 | beforeEach(() => { 67 | const chromeOptions = new ChromeOptions(); 68 | chromeOptions.addArguments( 69 | 'headless', 70 | 'disable-gpu', 71 | 'no-first-run', 72 | 'no-sandbox', 73 | ); 74 | 75 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 76 | }); 77 | 78 | afterEach((done) => { 79 | driver.quit().then(() => { done(); }); 80 | }); 81 | 82 | it('can run basic selenium test', (done) => { 83 | driver.get(`${mockServerUrl}/base.html`).then(() => { done(); }); 84 | }); 85 | 86 | it('can run another selenium test', (done) => { 87 | driver.get(`${mockServerUrl}/base.html`).then(() => { done(); }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/base-proxy-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const HttpServer = require('..').HttpServer; 28 | 29 | const Driver = require('../clients/js/chrome_driver_proxy'); 30 | const Chrome = require('selenium-webdriver/chrome').Driver; 31 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 32 | 33 | const chromedriverBin = process.env.CHROMEDRIVER_BIN || '/usr/bin/chromedriver'; 34 | 35 | describe('Proxy with chrome pool disabled', () => { 36 | let server; 37 | let driver; 38 | const mockServerUrl = 'http://127.0.0.1:8080'; 39 | 40 | before(function (done) { 41 | const config = { 42 | timeout: 2000, 43 | proxy: { 44 | port: 4444, 45 | baseUrl: '/wd/hub', 46 | }, 47 | chromedriver: { 48 | chromedriverPath: chromedriverBin, 49 | port: 4445, 50 | autoRestart: false, 51 | }, 52 | chromePool: { 53 | chromePath: '/usr/bin/google-chrome', 54 | enable: false, 55 | }, 56 | }; 57 | server = new HttpServer(config.proxy); 58 | this.timeout(5000); 59 | server.start(config, done); 60 | }); 61 | 62 | after((done) => { 63 | server.stop(done); 64 | }); 65 | 66 | beforeEach(() => { 67 | const chromeOptions = new ChromeOptions(); 68 | chromeOptions.addArguments( 69 | 'headless', 70 | 'disable-gpu', 71 | 'no-first-run', 72 | 'no-sandbox', 73 | ); 74 | chromeOptions.setChromeBinaryPath('/usr/bin/google-chrome'); 75 | 76 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 77 | }); 78 | 79 | afterEach((done) => { 80 | driver.quit().then(() => { done(); }); 81 | }); 82 | 83 | it('can run basic selenium test', (done) => { 84 | driver.get(`${mockServerUrl}/base.html`).then(() => { done(); }); 85 | }); 86 | 87 | it('can run another selenium test', (done) => { 88 | driver.get(`${mockServerUrl}/base.html`).then(() => { done(); }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /test/chrome-pool-navigate-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const HttpServer = require('..').HttpServer; 28 | 29 | const Driver = require('../clients/js/chrome_driver_proxy'); 30 | const Chrome = require('selenium-webdriver/chrome').Driver; 31 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 32 | 33 | const chromedriverBin = process.env.CHROMEDRIVER_BIN || '/usr/bin/chromedriver'; 34 | 35 | // need chrome 64+ see https://bugs.chromium.org/p/chromium/issues/detail?id=767683 36 | describe.skip('Proxy with navigate', () => { 37 | let server; 38 | let driver; 39 | const mockServerUrl = 'http://127.0.0.1:8080'; 40 | 41 | before(function (done) { 42 | const config = { 43 | proxy: { 44 | port: 4444, 45 | baseUrl: '/wd/hub', 46 | }, 47 | chromedriver: { 48 | chromedriverPath: chromedriverBin, 49 | port: 4445, 50 | autoRestart: false, 51 | }, 52 | chromePool: { 53 | enable: true, 54 | reuse: true, 55 | chromePath: '/usr/bin/google-chrome', 56 | }, 57 | }; 58 | server = new HttpServer(config.proxy); 59 | this.timeout(5000); 60 | server.start(config, done); 61 | }); 62 | 63 | after((done) => { 64 | server.stop(done); 65 | }); 66 | 67 | beforeEach(() => { 68 | const chromeOptions = new ChromeOptions(); 69 | chromeOptions.addArguments( 70 | 'headless', 71 | 'disable-gpu', 72 | 'no-first-run', 73 | 'no-sandbox', 74 | ); 75 | 76 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 77 | }); 78 | 79 | afterEach((done) => { 80 | driver.quit().then(() => { done(); }); 81 | }); 82 | 83 | it('can navigate with custom referrer', (done) => { 84 | const referrer = 'https://google.com'; 85 | driver.pageNavigate({url: `${mockServerUrl}/headers`, referrer: referrer}).then(() => { 86 | return driver.findElement({ css: '#referrer span.value' }); 87 | }) 88 | .then(elem => elem.getText()) 89 | .then((value) => { 90 | expect(value).to.equal(referrer); 91 | }) 92 | .then(() => { 93 | done(); 94 | }) 95 | .catch((err) => { 96 | done(err); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/chrome-pool-screen-recording-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const HttpServer = require('..').HttpServer; 28 | const fs = require('fs'); 29 | 30 | const Driver = require('../clients/js/chrome_driver_proxy'); 31 | const Chrome = require('selenium-webdriver/chrome').Driver; 32 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 33 | 34 | const chromedriverBin = process.env.CHROMEDRIVER_BIN || '/usr/bin/chromedriver'; 35 | 36 | describe('Proxy with screen recording', () => { 37 | let server; 38 | let driver; 39 | const mockServerUrl = 'http://127.0.0.1:8080'; 40 | 41 | before((done) => { 42 | const config = { 43 | proxy: { 44 | port: 4444, 45 | baseUrl: '/wd/hub', 46 | }, 47 | chromedriver: { 48 | chromedriverPath: chromedriverBin, 49 | port: 4445, 50 | autoRestart: false, 51 | }, 52 | chromePool: { 53 | enable: true, 54 | reuse: true, 55 | chromePath: '/usr/bin/google-chrome', 56 | chromeAgent: { 57 | screenRecorder: { 58 | videoFormat: 'mp4', 59 | }, 60 | }, 61 | }, 62 | }; 63 | server = new HttpServer(config.proxy); 64 | server.start(config, done); 65 | }); 66 | 67 | after((done) => { 68 | server.stop(done); 69 | }); 70 | 71 | beforeEach(() => { 72 | const chromeOptions = new ChromeOptions(); 73 | chromeOptions.addArguments( 74 | 'headless', 75 | 'disable-gpu', 76 | 'no-first-run', 77 | 'no-sandbox', 78 | ); 79 | 80 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 81 | }); 82 | 83 | afterEach((done) => { 84 | driver.quit().then(() => { done(); }); 85 | }); 86 | 87 | it('can record video', (done) => { 88 | driver.startScreencast({ params: { format: 'jpeg', quality: 80, everyNthFrame: 1 } }).then(() => driver.get(`${mockServerUrl}/base.html`)).then(() => driver.get(`${mockServerUrl}/cookies`)).then(() => driver.stopScreencast()) 89 | .then(result => driver.getScreencastPath()) 90 | .then((result) => { 91 | console.log(result); 92 | expect(result).to.have.property('path'); 93 | expect(fs.existsSync(result['path'])).to.be.true; 94 | done(); 95 | }) 96 | .catch((err) => { 97 | done(err); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /clients/py/chrome_driver_proxy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2017 ZipRecruiter 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | from __future__ import absolute_import 24 | import selenium.webdriver as wd 25 | from selenium.webdriver.remote.errorhandler import ErrorHandler 26 | from selenium.webdriver.remote.remote_connection import RemoteConnection 27 | 28 | class ChromeDriverProxyRemoteConnection(RemoteConnection): 29 | 30 | def __init__(self, remote_server_addr, keep_alive=True): 31 | RemoteConnection.__init__(self, remote_server_addr, keep_alive) 32 | self._commands["startScreencast"] = ('POST', '/session/$sessionId/chromedriver-proxy/screencast/start') 33 | self._commands["stopScreencast"] = ('POST', '/session/$sessionId/chromedriver-proxy/screencast/stop') 34 | self._commands["getScreencastPath"] = ('GET', '/session/$sessionId/chromedriver-proxy/screencast/path') 35 | self._commands["getScreencastS3"] = ('GET', '/session/$sessionId/chromedriver-proxy/screencast/s3') 36 | self._commands["setHeaders"] = ('POST', '/session/$sessionId/chromedriver-proxy/headers') 37 | self._commands["addScript"] = ('POST', '/session/$sessionId/chromedriver-proxy/script') 38 | self._commands["removeAllScripts"] = ('DELETE', '/session/$sessionId/chromedriver-proxy/scripts') 39 | self._commands["setClearStorage"] = ('POST', '/session/$sessionId/chromedriver-proxy/storage') 40 | self._commands["navigate"] = ('POST', '/session/$sessionId/chromedriver-proxy/navigate') 41 | 42 | 43 | class ChromeDriverProxy(wd.Remote): 44 | 45 | def __init__(self, *args, **kwargs): 46 | kwargs['command_executor'] = ChromeDriverProxyRemoteConnection(kwargs['command_executor'], keep_alive=kwargs['keep_alive']) 47 | super(self.__class__, self).__init__(*args, **kwargs) 48 | self.error_handler = ErrorHandler() 49 | 50 | def start_screencast(self, **kwargs): 51 | self.execute('startScreencast', kwargs) 52 | 53 | def stop_screencast(self, **kwargs): 54 | result = self.execute('stopScreencast', kwargs) 55 | return result['value'] 56 | 57 | def get_screencast_path(self): 58 | result = self.execute('getScreencastPath') 59 | return result['value']['path'] 60 | 61 | def get_screencast_s3(self): 62 | result = self.execute('getScreencastS3') 63 | return result['value'] 64 | 65 | def set_extra_headers(self, headers): 66 | self.execute('setHeaders', dict(headers=headers)) 67 | 68 | def add_script(self, script): 69 | self.execute('addScript', dict(scriptSource=script)) 70 | 71 | def remove_all_scripts(self): 72 | self.execute('removeAllScripts') 73 | 74 | def setClearStorage(self, options): 75 | self.execute('setClearStorage', dict(values=options)) 76 | 77 | def navigate(self, options): 78 | self.execute('navigate', dict(values=options)) 79 | -------------------------------------------------------------------------------- /test/chrome-pool-extra-headers-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const HttpServer = require('..').HttpServer; 28 | 29 | const Driver = require('../clients/js/chrome_driver_proxy'); 30 | const Chrome = require('selenium-webdriver/chrome').Driver; 31 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 32 | 33 | const chromedriverBin = process.env.CHROMEDRIVER_BIN || '/usr/bin/chromedriver'; 34 | 35 | describe('Proxy with extra headers', () => { 36 | let server; 37 | let driver; 38 | const mockServerUrl = 'http://127.0.0.1:8080'; 39 | 40 | before(function (done) { 41 | const config = { 42 | proxy: { 43 | port: 4444, 44 | baseUrl: '/wd/hub', 45 | }, 46 | chromedriver: { 47 | chromedriverPath: chromedriverBin, 48 | port: 4445, 49 | autoRestart: false, 50 | }, 51 | chromePool: { 52 | enable: true, 53 | reuse: true, 54 | chromePath: '/usr/bin/google-chrome', 55 | }, 56 | }; 57 | server = new HttpServer(config.proxy); 58 | this.timeout(5000); 59 | server.start(config, done); 60 | }); 61 | 62 | after((done) => { 63 | server.stop(done); 64 | }); 65 | 66 | beforeEach(() => { 67 | const chromeOptions = new ChromeOptions(); 68 | chromeOptions.addArguments( 69 | 'headless', 70 | 'disable-gpu', 71 | 'no-first-run', 72 | 'no-sandbox', 73 | ); 74 | 75 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 76 | }); 77 | 78 | afterEach((done) => { 79 | driver.quit().then(() => { done(); }); 80 | }); 81 | 82 | // need chrome 64+ see https://bugs.chromium.org/p/chromium/issues/detail?id=767683 83 | it('can add extra headers', (done) => { 84 | driver.get(`${mockServerUrl}/base.html`).then(() => { return driver.setExtraHeaders({ Foo: 'bar' }); }).then(() => driver.get(`${mockServerUrl}/headers`)).then(() => driver.getPageSource()) 85 | .then((source) => { 86 | console.log(source); 87 | return driver.findElement({ css: '#foo span.value' }); 88 | }) 89 | .then(elem => elem.getText()) 90 | .then((value) => { 91 | expect(value).to.equal('bar'); 92 | }) 93 | .then(() => { 94 | done(); 95 | }) 96 | .catch((err) => { 97 | done(err); 98 | }); 99 | }); 100 | it('can set custom user agent', (done) => { 101 | driver.get(`${mockServerUrl}/base.html`).then(() => driver.setUserAgent('bandit')).then(() => driver.get(`${mockServerUrl}/headers`)).then(() => driver.getPageSource()) 102 | .then((source) => { 103 | console.log(source); 104 | return driver.findElement({ css: '#user-agent span.value' }); 105 | }) 106 | .then(elem => elem.getText()) 107 | .then((value) => { 108 | expect(value).to.equal('bandit'); 109 | }) 110 | .then(() => { 111 | done(); 112 | }) 113 | .catch((err) => { 114 | done(err); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/chrome-pool-script-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const HttpServer = require('..').HttpServer; 28 | 29 | const Driver = require('../clients/js/chrome_driver_proxy'); 30 | const Chrome = require('selenium-webdriver/chrome').Driver; 31 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 32 | 33 | const chromedriverBin = process.env.CHROMEDRIVER_BIN || '/usr/bin/chromedriver'; 34 | 35 | describe('Proxy with script support', () => { 36 | let server; 37 | let driver; 38 | const mockServerUrl = 'http://127.0.0.1:8080'; 39 | 40 | before(function (done) { 41 | const config = { 42 | proxy: { 43 | port: 4444, 44 | baseUrl: '/wd/hub', 45 | }, 46 | chromedriver: { 47 | chromedriverPath: chromedriverBin, 48 | port: 4445, 49 | autoRestart: false, 50 | }, 51 | chromePool: { 52 | enable: true, 53 | reuse: true, 54 | chromePath: '/usr/bin/google-chrome', 55 | }, 56 | }; 57 | server = new HttpServer(config.proxy); 58 | this.timeout(5000); 59 | server.start(config, done); 60 | }); 61 | 62 | after((done) => { 63 | server.stop(done); 64 | }); 65 | 66 | beforeEach(() => { 67 | const chromeOptions = new ChromeOptions(); 68 | chromeOptions.addArguments( 69 | 'headless', 70 | 'disable-gpu', 71 | 'no-first-run', 72 | 'no-sandbox', 73 | ); 74 | 75 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 76 | }); 77 | 78 | afterEach((done) => { 79 | driver.quit().then(() => { done(); }); 80 | }); 81 | 82 | it('can add a script', (done) => { 83 | driver.addScript('window.FOO="BAR"').then(() => driver.get(`${mockServerUrl}/base.html`)).then(() => { 84 | return driver.executeScript('return window.FOO'); 85 | }) 86 | .then((result) => { 87 | expect(result).to.equal('BAR'); 88 | }) 89 | .then(() => { 90 | done(); 91 | }) 92 | .catch((err) => { 93 | done(err); 94 | }); 95 | }); 96 | 97 | it('can add a script on navigation', (done) => { 98 | driver.addScript('window.FOO="BAR"').then(() => driver.get(`${mockServerUrl}/base.html`)).then(() => { 99 | return driver.findElement({css: 'a[href*="base2.html"]'}); 100 | }) 101 | .then((elem) => { 102 | return elem.click() 103 | }).then(() => { 104 | return driver.getTitle() 105 | }).then((title) => { 106 | expect(title).to.equal('base 2 page'); 107 | }).then(() => { 108 | return driver.executeScript('return window.FOO'); 109 | }) 110 | .then((result) => { 111 | expect(result).to.equal('BAR'); 112 | }) 113 | .then(() => { 114 | done(); 115 | }) 116 | .catch((err) => { 117 | done(err); 118 | }); 119 | }); 120 | 121 | 122 | xit('can remove all scripts', (done) => { 123 | driver.addScript('window.FOO="BAR"').then((result) => { 124 | return driver.get(`${mockServerUrl}/base.html`) 125 | }).then((result) => { 126 | return driver.removeAllScripts() 127 | }) 128 | .then((result) => { 129 | return driver.get(`${mockServerUrl}/base2.html`) 130 | }).then(() => { 131 | return driver.executeScript('return window.FOO'); 132 | }) 133 | .then((result) => { 134 | expect(result).to.not.equal('BAR'); 135 | }).then(() => { 136 | done(); 137 | }) 138 | .catch((err) => { 139 | done(err); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://img.shields.io/npm/v/chromedriver-proxy.svg?style=flat-square)](https://www.npmjs.com/package/chromedriver-proxy) 2 | 3 | Chromedriver-Proxy is an extensible proxy to ChromeDriver. 4 | 5 | # Features 6 | 7 | * Reuse browsers. The browser will be cleaned between each selenium session. 8 | * Connect to Chromedriver from remote host without modifying the whitelist 9 | * Record video and upload the video to s3. Compatible with chrome in headless mode. 10 | * Support mp4, webm, and m3u8 video format 11 | * Set extra headers 12 | * Evaluate script on each page load 13 | * Provide your own custom extensions. 14 | 15 | # Requirements 16 | 17 | nodejs >= 8, ffmpeg, chrome >= 64 and the current version of [chromedriver](https://sites.google.com/a/chromium.org/chromedriver/downloads). 18 | 19 | # Usage 20 | 21 | ``` 22 | DEBUG=chromedriver_proxy:* chromedriver-proxy --config config.json 23 | ``` 24 | 25 | example configuration: 26 | ```json 27 | { 28 | "tmpDir": "/tmp", 29 | "proxy": { 30 | "port": 4444, 31 | "baseUrl": "/wd/hub" 32 | }, 33 | "chromedriver": { 34 | "chromedriverPath": "/usr/bin/chromedriver", 35 | "port": 4445, 36 | "autoRestart": true // restart chromedriver if it crashes 37 | }, 38 | "chromePool": { 39 | "enable": true, 40 | "chromePath": "/usr/bin/google-chrome", 41 | "reuse": true, // reuse the browser instances 42 | "chromeStartupTimeOut": 1000, // time to wait for chrome to startup 43 | // chromeAgentModule should extend the builtin ChromeAgent. 44 | "chromeAgentModule": "path to custom module", 45 | "clearStorage": [ 46 | // https://chromedevtools.github.io/devtools-protocol/tot/Storage/#method-clearDataForOrigin 47 | { 48 | "origin": ".ziprecruiter.com", 49 | "storageTypes": "cookies,localstorage" 50 | } 51 | ], 52 | "chromeAgent": { 53 | "screenRecorder": { 54 | "videoFormat": "", 55 | 56 | "s3": { 57 | "region": "" 58 | // additional options to the constructor http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property 59 | }, 60 | "s3Upload": { 61 | "Bucket": "" 62 | // additional options to the upload function http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#upload-property 63 | } 64 | } 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | ## Clients 71 | 72 | The project provides a [python](clients/py) client and a [javascript](clients/js) client. See the [examples](examples) for basic usage. 73 | 74 | Feel free to add support for any of the languages that the selenium project supports (java, python, javascript, c#, ruby). 75 | 76 | ## Issues 77 | 78 | Please report any issues using the [ChromeDriver Proxy issue tracker](https://github.com/ZipRecruiter/chromedriver-proxy/issues). When using 79 | the issue tracker 80 | 81 | - __Do__ include a detailed description of the problem. 82 | - __Do__ include a link to a [gist](http://gist.github.com/) with any 83 | interesting stack traces/logs (you may also attach these directly to the bug 84 | report). 85 | - __Do__ include a reduced test case. 86 | - __Do not__ use the issue tracker to submit basic help requests. 87 | - __Do not__ post empty "I see this too" or "Any updates?" comments. These 88 | provide no additional information and clutter the log. 89 | - __Do not__ report regressions on closed bugs as they are not actively 90 | monitored for updates (especially bugs that are >6 months old). Please open a 91 | new issue and reference the original bug in your report. 92 | 93 | # License 94 | 95 | Copyright (c) 2017 ZipRecruiter 96 | 97 | Permission is hereby granted, free of charge, to any person obtaining a copy 98 | of this software and associated documentation files (the "Software"), to deal 99 | in the Software without restriction, including without limitation the rights 100 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 101 | copies of the Software, and to permit persons to whom the Software is 102 | furnished to do so, subject to the following conditions: 103 | 104 | The above copyright notice and this permission notice shall be included in all 105 | copies or substantial portions of the Software. 106 | 107 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 108 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 109 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 110 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 111 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 112 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 113 | SOFTWARE. 114 | -------------------------------------------------------------------------------- /lib/chromedriver.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const { spawn } = require('child_process'); 26 | const debug = require('debug')('chromedriver_proxy:chromedriver'); 27 | const http = require('http'); 28 | 29 | class ChromeDriver { 30 | constructor(options) { 31 | this.port = options.port || 4445; 32 | this.autoRestart = options.autoRestart || false; 33 | this.chromeDriverPath = options.chromedriverPath || '/usr/bin/chromedriver'; 34 | this.args = options.args || []; 35 | this.shutdown = false; 36 | } 37 | 38 | createArgs() { 39 | const self = this; 40 | const args = []; 41 | args.push(`--port=${self.port}`); 42 | 43 | for (let i = 0; i < self.args.length; i += 1) { 44 | args.push(self.args[i]); 45 | } 46 | 47 | return args; 48 | } 49 | 50 | verifyStarted() { 51 | const self = this; 52 | const statusOptions = { 53 | timeout: 2000, 54 | port: self.port, 55 | host: '127.0.0.1', 56 | path: '/status', 57 | }; 58 | return new Promise((resolve, reject) => { 59 | http.get(statusOptions, (res) => { 60 | if (res.statusCode === 200) { 61 | resolve(); 62 | } else { 63 | reject(new Error(`chromedriver status: ${res.statusCode}`)); 64 | } 65 | }).on('socket', (socket) => { 66 | socket.setTimeout(2000); 67 | }).on('error', (err) => { 68 | debug(err.stack); 69 | reject(err); 70 | }); 71 | }); 72 | } 73 | 74 | start() { 75 | const self = this; 76 | const args = self.createArgs(); 77 | 78 | debug(`${self.chromeDriverPath} ${args.join(' ')}`); 79 | 80 | return new Promise((resolve, reject) => { 81 | const exitListener = function exitListener(code, signal) { 82 | debug(`chromedriver exited with status: ${code} signal: ${signal}`); 83 | reject(); 84 | }; 85 | const startupListener = function startupListener(data) { 86 | const chunk = data.toString('utf-8'); 87 | if (chunk.indexOf('Starting ChromeDriver') !== -1) { 88 | console.log(chunk); 89 | self.child.removeListener('exit', exitListener); 90 | self.child.stdout.removeListener('data', startupListener); 91 | } 92 | const maxRetries = 3; 93 | let retryCount = 0; 94 | const verifyChromedriverConn = () => { 95 | self.verifyStarted().then(() => { 96 | resolve(); 97 | }).catch((err) => { 98 | retryCount += 1; 99 | if (retryCount < maxRetries) { 100 | setTimeout(verifyChromedriverConn, 50); 101 | } else { 102 | reject(err); 103 | } 104 | }); 105 | }; 106 | verifyChromedriverConn(); 107 | }; 108 | self.child = spawn(self.chromeDriverPath, args); 109 | self.child.addListener('exit', exitListener); 110 | self.child.stdout.addListener('data', startupListener); 111 | 112 | debug(`ChromeDriver Pid: ${self.child.pid}`); 113 | 114 | self.child.on('close', () => { 115 | if (self.shutdown || !self.autoRestart) { return; } 116 | 117 | setTimeout(() => { 118 | debug('Restarting chromediver'); 119 | self.start(); 120 | }, 10); 121 | }); 122 | }); 123 | } 124 | 125 | stop() { 126 | const self = this; 127 | debug('stop chromedriver'); 128 | self.shutdown = true; 129 | return new Promise((resolve) => { 130 | self.child.once('exit', (code, signal) => { 131 | debug(`chromedriver exited: ${code} ${signal}`); 132 | resolve(); 133 | }); 134 | self.child.kill('SIGKILL'); 135 | }); 136 | } 137 | } 138 | 139 | module.exports = ChromeDriver; 140 | -------------------------------------------------------------------------------- /clients/js/chrome_driver_proxy.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const Chrome = require('selenium-webdriver/chrome').Driver; 26 | const http = require('selenium-webdriver/http'); 27 | const command = require('selenium-webdriver/lib/command'); 28 | const Capabilities = require('selenium-webdriver/lib/capabilities').Capabilities; 29 | 30 | const Command = { 31 | START_SCREENCAST: 'startScreencast', 32 | STOP_SCREENCAST: 'stopScreencast', 33 | GET_SCREENCAST_PATH: 'getScreencastPath', 34 | GET_SCREENCAST_S3: 'getScreencastS3', 35 | SET_HEADERS: 'setHeaders', 36 | SET_USER_AGENT: 'setUserAgent', 37 | ADD_SCRIPT: 'addScript', 38 | REMOVE_ALL_SCRIPTS: 'removeAllScripts', 39 | SET_CLEAR_STORAGE: 'setClearStorage', 40 | }; 41 | 42 | function configureExecutor(executor) { 43 | executor.defineCommand( 44 | Command.START_SCREENCAST, 45 | 'POST', 46 | '/session/:sessionId/chromedriver-proxy/screencast/start', 47 | ); 48 | executor.defineCommand( 49 | Command.STOP_SCREENCAST, 50 | 'POST', 51 | '/session/:sessionId/chromedriver-proxy/screencast/stop', 52 | ); 53 | executor.defineCommand( 54 | Command.GET_SCREENCAST_PATH, 55 | 'GET', 56 | '/session/:sessionId/chromedriver-proxy/screencast/path', 57 | ); 58 | executor.defineCommand( 59 | Command.GET_SCREENCAST_S3, 60 | 'GET', 61 | '/session/:sessionId/chromedriver-proxy/screencast/s3', 62 | ); 63 | executor.defineCommand( 64 | Command.SET_EXTRA_HEADERS, 65 | 'POST', 66 | '/session/:sessionId/chromedriver-proxy/headers', 67 | ); 68 | executor.defineCommand( 69 | Command.SET_USER_AGENT, 70 | 'POST', 71 | '/session/:sessionId/chromedriver-proxy/useragent', 72 | ); 73 | executor.defineCommand( 74 | Command.ADD_SCRIPT, 75 | 'POST', 76 | '/session/:sessionId/chromedriver-proxy/script', 77 | ); 78 | executor.defineCommand( 79 | Command.REMOVE_ALL_SCRIPTS, 80 | 'DELETE', 81 | '/session/:sessionId/chromedriver-proxy/scripts', 82 | ); 83 | executor.defineCommand( 84 | Command.SET_CLEAR_STORAGE, 85 | 'POST', 86 | '/session/:sessionId/chromedriver-proxy/storage', 87 | ); 88 | // executor.defineCommand( 89 | // Command.NAVIGATE, 90 | // 'POST', 91 | // '/session/:sessionId/chromedriver-proxy/navigate', 92 | // ); 93 | } 94 | 95 | function createExecutor(url) { 96 | const client = url.then(u => new http.HttpClient(u)); 97 | const executor = new http.Executor(client); 98 | configureExecutor(executor); 99 | return executor; 100 | } 101 | 102 | 103 | class Driver extends Chrome { 104 | 105 | static createSession(url, opts) { 106 | const caps = Capabilities.chrome(); 107 | caps.merge(opts); 108 | 109 | let client = Promise.resolve(url).then( 110 | (url) => new http.HttpClient(url) 111 | ) 112 | let executor = new http.Executor(client); 113 | 114 | return super.createSession(caps, createExecutor(Promise.resolve(url)), null);; 115 | } 116 | 117 | startScreencast(params) { 118 | return this.execute( 119 | new command.Command(Command.START_SCREENCAST).setParameters(params), 120 | 'ChromeDriverProxy.startScreencast', 121 | ); 122 | } 123 | 124 | stopScreencast() { 125 | return this.execute( 126 | new command.Command(Command.STOP_SCREENCAST), 127 | 'ChromeDriverProxy.stopScreencast', 128 | ); 129 | } 130 | 131 | getScreencastPath() { 132 | return this.execute( 133 | new command.Command(Command.GET_SCREENCAST_PATH), 134 | 'ChromeDriverProxy.getScreencastPath', 135 | ); 136 | } 137 | 138 | getScreencastS3() { 139 | return this.execute( 140 | new command.Command(Command.GET_SCREENCAST_S3), 141 | 'ChromeDriverProxy.getScreencastS3', 142 | ); 143 | } 144 | 145 | setExtraHeaders(headers) { 146 | return this.execute( 147 | new command.Command(Command.SET_EXTRA_HEADERS).setParameter('headers', headers), 148 | 'ChromeDriverProxy.setExtraHeaders', 149 | ); 150 | } 151 | 152 | setUserAgent(userAgent) { 153 | return this.execute( 154 | new command.Command(Command.SET_USER_AGENT).setParameter('userAgent', userAgent), 155 | 'ChromeDriverProxy.setUserAgent', 156 | ); 157 | } 158 | 159 | addScript(script) { 160 | return this.execute( 161 | new command.Command(Command.ADD_SCRIPT).setParameter('scriptSource', script), 162 | 'ChromeDriverProxy.addScript', 163 | ); 164 | } 165 | 166 | removeAllScripts() { 167 | return this.execute( 168 | new command.Command(Command.REMOVE_ALL_SCRIPTS), 169 | 'ChromeDriverProxy.removeAllScripts', 170 | ); 171 | } 172 | 173 | setClearStorage(options) { 174 | return this.execute( 175 | new command.Command(Command.SET_CLEAR_STORAGE).setParameter('values', options), 176 | 'ChromeDriverProxy.setClearStorage', 177 | ); 178 | } 179 | 180 | // pageNavigate(options) { 181 | // return this.execute( 182 | // new command.Command(Command.NAVIGATE).setParameter('options', options), 183 | // 'ChromeDriverProxy.NAVIGATE', 184 | // ); 185 | // } 186 | } 187 | 188 | module.exports = Driver; 189 | -------------------------------------------------------------------------------- /test/chrome-pool-cookies-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017 ZipRecruiter 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | * */ 22 | 23 | 24 | const chai = require('chai'); 25 | 26 | const expect = chai.expect; 27 | const HttpServer = require('..').HttpServer; 28 | 29 | const Driver = require('../clients/js/chrome_driver_proxy'); 30 | const Chrome = require('selenium-webdriver/chrome').Driver; 31 | const ChromeOptions = require('selenium-webdriver/chrome').Options; 32 | 33 | const chromedriverBin = process.env.CHROMEDRIVER_BIN || '/usr/bin/chromedriver'; 34 | 35 | describe('Proxy with chrome pool cookie test', () => { 36 | let server; 37 | let driver; 38 | let options; 39 | let chromeOptions; 40 | const mockServerUrl = 'http://127.0.0.1:8080'; 41 | 42 | beforeEach((done) => { 43 | const config = { 44 | proxy: { 45 | port: 4444, 46 | baseUrl: '/wd/hub', 47 | }, 48 | chromedriver: { 49 | chromedriverPath: chromedriverBin, 50 | port: 4445, 51 | autoRestart: false, 52 | }, 53 | chromePool: { 54 | enable: true, 55 | reuse: true, 56 | chromePath: '/usr/bin/google-chrome', 57 | clearStorage: [ 58 | { 59 | origin: '.localhost', 60 | storageTypes: 'cookies,localstorage', 61 | }, 62 | ], 63 | }, 64 | }; 65 | server = new HttpServer(config.proxy); 66 | server.start(config, () => { 67 | chromeOptions = new ChromeOptions(); 68 | chromeOptions.addArguments( 69 | '--headless', 70 | '--disable-gpu', 71 | '--no-first-run', 72 | '--no-sandbox', 73 | '--disable-dev-shm-usage', 74 | ); 75 | chromeOptions.setChromeBinaryPath('/usr/bin/google-chrome'); 76 | 77 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 78 | done(); 79 | }); 80 | }); 81 | 82 | afterEach((done) => { 83 | server.stop(done); 84 | }); 85 | 86 | it('can clear cookies between sessions', (done) => { 87 | driver.get(`${mockServerUrl}/base.html`).then(() => driver.manage().addCookie({ name: 'foo', value: 'bar' })).then(() => driver.get(`${mockServerUrl}/cookies`)).then(() => driver.getPageSource()) 88 | .then(source => driver.findElement({ css: '#foo span.value' })) 89 | .then(elem => elem.getText()) 90 | .then((cookieValue) => { 91 | expect(cookieValue).to.equal('bar'); 92 | return driver.quit(); 93 | }) 94 | .then(() => { 95 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 96 | return driver.get(`${mockServerUrl}/base.html`); 97 | }) 98 | .then(() => { 99 | return driver.manage().getCookie('foo').catch((err) => { 100 | return null; 101 | }); 102 | }) 103 | .then((cookie) => { 104 | expect(cookie).to.be.null; 105 | }) 106 | .then(() => driver.quit()) 107 | .then(() => { 108 | done(); 109 | }) 110 | .catch((err) => { 111 | done(err); 112 | }); 113 | }); 114 | 115 | it('can override default for clearing local storage', (done) => { 116 | driver.get(`${mockServerUrl}/base.html`).then(() => driver.setClearStorage([])).then(() => driver.get(`${mockServerUrl}/cookies`)).then(() => driver.executeScript('window.localStorage.setItem("foo", "bar")')) 117 | .then(() => driver.executeScript('return window.localStorage.getItem("foo")')) 118 | .then((value) => { 119 | expect(value).to.equal('bar'); 120 | return driver.quit(); 121 | }) 122 | .then(() => { 123 | // TODO find a better way to wait for the browser to be available in the pool 124 | return driver.sleep(5); 125 | }).then(() => { 126 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 127 | return driver.get(`${mockServerUrl}/base.html`); 128 | }) 129 | .then(() => driver.executeScript('return window.localStorage.getItem("foo")')) 130 | .then((value) => { 131 | expect(value).to.equal('bar'); 132 | }) 133 | .then(() => driver.quit()) 134 | .then(() => { 135 | done(); 136 | }) 137 | .catch((err) => { 138 | done(err); 139 | }); 140 | }); 141 | 142 | it('can clear local storage between sessions', (done) => { 143 | driver.get(`${mockServerUrl}/base.html`).then(() => driver.manage().addCookie({ name: 'foo', value: 'bar' })).then(() => driver.get(`${mockServerUrl}/cookies`)).then(() => driver.executeScript('window.localStorage.setItem("foo", "bar")')) 144 | .then(() => driver.executeScript('return window.localStorage.getItem("foo")')) 145 | .then((value) => { 146 | expect(value).to.equal('bar'); 147 | return driver.quit(); 148 | }) 149 | .then(() => { 150 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 151 | return driver.get(`${mockServerUrl}/base.html`); 152 | }) 153 | .then(() => driver.executeScript('return window.localStorage.getItem("foo")')) 154 | .then((value) => { 155 | expect(value).to.be.null; 156 | }) 157 | .then(() => driver.quit()) 158 | .then(() => { 159 | done(); 160 | }) 161 | .catch((err) => { 162 | done(err); 163 | }); 164 | }); 165 | 166 | it('can close extra windows between sessions', (done) => { 167 | driver.get(`${mockServerUrl}/base.html`).then(() => driver.executeScript(`open("${mockServerUrl}/base.html"); open("${mockServerUrl}/base.html")`)).then(() => driver.getAllWindowHandles()).then((handles) => { 168 | expect(handles.length).to.equal(3); 169 | return driver.quit(); 170 | }) 171 | .then(() => { 172 | driver = Driver.createSession('http://127.0.0.1:4444/wd/hub', chromeOptions); 173 | return driver.get(`${mockServerUrl}/base.html`); 174 | }) 175 | .then(() => driver.getAllWindowHandles()) 176 | .then((handles) => { 177 | expect(handles.length).to.equal(1); 178 | return driver.quit(); 179 | }) 180 | .then(() => { 181 | done(); 182 | }) 183 | .catch((err) => { 184 | done(err); 185 | }); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /lib/chrome_pool.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const crypto = require('crypto'); 26 | const getPort = require('get-port'); 27 | const { execFile } = require('child_process'); 28 | const CDP = require('chrome-remote-interface'); 29 | const debug = require('debug')('chromedriver_proxy:chrome_pool'); 30 | const debugBrowser = require('debug')('chromedriver_proxy:chrome_pool_browser'); 31 | const path = require('path'); 32 | const ChromeAgentManager = require('./chrome_agent_manager'); 33 | 34 | class ChromePool { 35 | constructor(options) { 36 | const o = options || {}; 37 | this.chrome_binary = o.chromePath || '/usr/bin/google-chrome'; 38 | this.chromeStorage = o.clearStorage || []; 39 | this.tmpDir = o.tmpDir || '/tmp'; 40 | this.reuse = o.reuse || false; 41 | this.enable = typeof o.enable === 'undefined' ? false : o.enable; 42 | this.chromeAgentModule = o.chromeAgentModule || `${__dirname}${path.sep}chrome_agent.js`; 43 | this.chromeAgentOptions = o.chromeAgent || {}; 44 | this.chromeStartupTimeOut = o.chromeStartupTimeOut || 1000; 45 | this.inavtivePool = []; 46 | this.pool = {}; 47 | this.agents = {}; 48 | } 49 | 50 | // takes the chrome command line args 51 | get(options) { 52 | const self = this; 53 | const args = options.args || []; 54 | let port = null; 55 | 56 | // transform args 57 | for (let i = 0; i < args.length; i += 1) { 58 | const arg = args[i]; 59 | if (arg.substring(0, 2) !== '--') { 60 | args[i] = `--${args[i]}`; 61 | } 62 | } 63 | 64 | for (let i = 0; i < self.inavtivePool.length; i += 1) { 65 | let match = false; 66 | const browser = self.pool[self.inavtivePool[i]]; 67 | if (browser === undefined) { 68 | debug(`ERROR: browser at port ${self.inavtivePool[i]} is undefined`); 69 | continue; 70 | } 71 | const currentArgs = browser.args; 72 | if (currentArgs.length === args.length) { 73 | match = true; 74 | } 75 | for (let j = 0; j < args.length; j += 1) { 76 | if (currentArgs[j] !== args[j]) { 77 | match = false; 78 | break; 79 | } 80 | } 81 | if (match) { 82 | port = self.inavtivePool[i]; 83 | self.inavtivePool.splice(i, 1); 84 | debug(`Reuse browser at port: ${port}`); 85 | return Promise.resolve(port); 86 | } 87 | } 88 | 89 | return self.startBrowser(options); 90 | } 91 | 92 | startBrowser(options) { 93 | const self = this; 94 | self.profile = `${self.tmpDir}/chrome-profile-${crypto.randomBytes(16).toString('hex')}`; 95 | const args = options.args ? options.args.slice(0) : []; 96 | args.reverse(); 97 | args.push(`--user-data-dir=${self.profile}`); 98 | 99 | return getPort().then((port) => { 100 | args.push(`--remote-debugging-port=${port}`); 101 | debug(`launch browser: ${self.chrome_binary} ${args.join(' ')}`); 102 | const browser = execFile(self.chrome_binary, args); 103 | browser.stderr.on('data', (chunk) => { 104 | debugBrowser(`browser port: ${port} pid: ${browser.pid} message: ${chunk.trim()}`); 105 | }); 106 | return new Promise((resolve, reject) => { 107 | browser.once('exit', (code) => { 108 | reject(Error(`browser port: ${port} pid: ${browser.pid} exit with code: ${code}`)); 109 | }); 110 | browser.stderr.once('data', () => { 111 | debug(`Started Browser: port => ${port} pid => ${browser.pid}`); 112 | 113 | // getting the target is temperamental so we retry on failure 114 | const endTime = Date.now() + self.chromeStartupTimeOut; 115 | const resolveTarget = function resolveTarget() { 116 | CDP.List({ host: '127.0.0.1', port }, (err, targets) => { 117 | if ((err && Date.now() <= endTime) || (typeof targets === 'undefined' || targets.length === 0)) { 118 | setTimeout(resolveTarget, 50); 119 | return; 120 | } else if (err) { 121 | reject(err); 122 | return; 123 | } 124 | self.pool[port] = { 125 | args: options.args, 126 | process: browser, 127 | target: targets[0].id, 128 | }; 129 | resolve(port); 130 | }); 131 | }; 132 | setTimeout(resolveTarget, 10); 133 | }); 134 | }); 135 | }); 136 | } 137 | 138 | stopBrowser(port) { 139 | const self = this; 140 | const p = self.pool[port].process; 141 | return new Promise((resolve) => { 142 | p.once('exit', (code, signal) => { 143 | debug(`browser at port: ${port} exited ${code} ${signal}`); 144 | resolve(); 145 | }); 146 | p.kill('SIGKILL'); 147 | debug(`SIGKILL sent to browser at port: ${port}`); 148 | delete self.pool[port]; 149 | }); 150 | } 151 | 152 | killAll() { 153 | const self = this; 154 | Object.keys(self.pool).forEach((v) => { 155 | self.pool[v].process.kill('SIGKILL'); 156 | }); 157 | self.pool = {}; 158 | self.inavtivePool = []; 159 | debug('kill all chrome instances'); 160 | } 161 | 162 | async put(port) { 163 | const self = this; 164 | if (self.reuse) { 165 | try { 166 | const agent = self.agents[port]; 167 | await agent.stop(); 168 | delete self.agents[port]; 169 | } catch (err) { 170 | debug(`error in chrome ${port} cleanup: ${err}`); 171 | delete self.agents[port]; 172 | return self.stopBrowser(port); 173 | } 174 | } else { 175 | return self.stopBrowser(port); 176 | } 177 | self.inavtivePool.push(port); 178 | return Promise.resolve(); 179 | } 180 | 181 | sendToAgent(options) { 182 | const self = this; 183 | return self.getAgent(options).then(agent => agent.send(options)); 184 | } 185 | 186 | getAgent(options) { 187 | const self = this; 188 | const { port } = options; 189 | 190 | if (self.agents[port]) { 191 | debug('agent already exists'); 192 | return Promise.resolve(self.agents[port]); 193 | } 194 | debug('agent does NOT already exists'); 195 | const agent = new ChromeAgentManager(); 196 | const chromePid = self.pool[port].process.pid; 197 | const defaultOptions = { 198 | chromeAgentModule: self.chromeAgentModule, 199 | host: '127.0.0.1', 200 | port, 201 | target: self.pool[port].target, 202 | chromeStorage: self.chromeStorage, 203 | chromePid, 204 | }; 205 | Object.assign(defaultOptions, self.chromeAgentOptions); 206 | agent.start(defaultOptions); 207 | self.agents[port] = agent; 208 | 209 | return Promise.resolve(agent); 210 | } 211 | } 212 | 213 | module.exports = ChromePool; 214 | -------------------------------------------------------------------------------- /lib/chrome_agent.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const process = require('process'); 26 | const CDP = require('chrome-remote-interface'); 27 | const debug = require('debug')('chromedriver_proxy:chrome_agent'); 28 | const gpath = require('path'); 29 | 30 | const ScreenRecorder = require(gpath.join(__dirname, 'screen_recorder')); 31 | const RealTimeScreenRecorder = require(gpath.join(__dirname, 'realtime_screen_recorder')); 32 | 33 | class ChromeAgent { 34 | constructor(options) { 35 | Object.keys(options).forEach((i) => { 36 | this[i] = options[i]; 37 | }); 38 | this.screenRecorderOptions = this.screenRecorder || {}; 39 | delete this.screenRecorder; 40 | this.scriptIds = new Set(); 41 | 42 | this.routes = [ 43 | ['POST', 'screencast/start', 'startScreencast'], 44 | ['POST', 'screencast/subtitle/add', 'addSubtitle'], 45 | ['POST', 'screencast/stop', 'stopScreencast'], 46 | ['GET', 'screencast/path', 'getScreencastPath'], 47 | ['GET', 'screencast/s3', 'getScreencastS3'], 48 | ['POST', 'headers', 'setHeaders'], 49 | ['POST', 'useragent', 'setUserAgent'], 50 | ['POST', 'script', 'addScript'], 51 | ['DELETE', 'scripts', 'removeAllScripts'], 52 | ['POST', 'storage', 'setClearStorage'], 53 | ['POST', 'navigate', 'navigate'], 54 | ]; 55 | } 56 | 57 | 58 | async setClearStorage(options) { 59 | const self = this; 60 | self.chromeStorage = options.values; 61 | return {}; 62 | } 63 | 64 | async getConn() { 65 | const self = this; 66 | if (self.client) { 67 | return self.client; 68 | } 69 | return new Promise((resolve, reject) => { 70 | CDP({ host: '127.0.0.1', port: self.port, target: self.target }, (client) => { 71 | self.client = client; 72 | resolve(client); 73 | }).on('error', (err) => { 74 | debug('unable to connect to debugger'); 75 | reject(err); 76 | }); 77 | }); 78 | } 79 | 80 | async navigate(options) { 81 | const self = this; 82 | const { Page } = await self.getConn(); 83 | await Page.enable(); 84 | debug(`navigate ${JSON.stringify(options.values)}`); 85 | const result = await Page.navigate(options.values); 86 | if (typeof result.errorText !== 'undefined') { 87 | debug(`navigation error ${result.errorText}`); 88 | return Promise.reject(new Error(result.errorText)); 89 | } 90 | return result; 91 | } 92 | 93 | async addScript(options) { 94 | const self = this; 95 | const opts = options; 96 | const { Page } = await self.getConn(); 97 | await Page.enable(); 98 | // for backwards compatibility 99 | if ('scriptSource' in opts) { 100 | opts.source = opts.scriptSource; 101 | delete opts.scriptSource; 102 | } 103 | debug(`add script ${JSON.stringify(opts)}`); 104 | // BUG returns an empty string for an identifier 105 | const result = await Page.addScriptToEvaluateOnNewDocument(opts); 106 | debug(`added script: ${JSON.stringify(result)}`); 107 | self.scriptIds.add(result.identifier); 108 | return result; 109 | } 110 | 111 | async removeAllScripts() { 112 | const self = this; 113 | const { Page } = await self.getConn(); 114 | await Page.enable(); 115 | debug('remove all scripts start'); 116 | const p = []; 117 | self.scriptIds.forEach((id) => { 118 | p.push(Page.removeScriptToEvaluateOnLoad({ identifier: id })); 119 | }); 120 | await Promise.all(p); 121 | self.scriptIds = new Set(); 122 | debug('remove all scripts done'); 123 | } 124 | 125 | async setHeaders(options) { 126 | const self = this; 127 | const { Network } = await self.getConn(); 128 | Network.enable(); 129 | debug(`set extra headers ${JSON.stringify(options)}`); 130 | return Network.setExtraHTTPHeaders({ headers: options.headers }); 131 | } 132 | 133 | async setUserAgent(options) { 134 | const self = this; 135 | const { Network } = await self.getConn(); 136 | debug(`set user agent ${JSON.stringify(options)}`); 137 | return Network.setUserAgentOverride({ userAgent: options.userAgent }); 138 | } 139 | 140 | async getScreencastPath() { 141 | const self = this; 142 | const result = await self.screenRecorderVideo; 143 | debug(`local screencast path: ${result}`); 144 | return { path: result }; 145 | } 146 | 147 | async getScreencastS3() { 148 | const self = this; 149 | const result = await self.screenRecorderVideo.then(() => self.sr.s3UploadResult); 150 | debug(`s3 upload: ${JSON.stringify(result)}`); 151 | return result; 152 | } 153 | 154 | async startScreencast(options) { 155 | const self = this; 156 | debug('start screencast'); 157 | const client = await self.getConn(); 158 | const defaultOptions = self.screenRecorderOptions; 159 | defaultOptions.client = client; 160 | if (options.format === 'm3u8') { 161 | self.sr = new RealTimeScreenRecorder(defaultOptions); 162 | } else { 163 | self.sr = new ScreenRecorder(defaultOptions); 164 | } 165 | 166 | const { Page } = client; 167 | await Page.enable(); 168 | debug(`screen recorder options ${JSON.stringify(options)}`); 169 | await self.sr.start(options); 170 | return self.sr.expectedResult(); 171 | } 172 | 173 | async stopScreencast(options) { 174 | const self = this; 175 | debug('stop screencast'); 176 | if (!self.sr) { 177 | throw new Error('must call startScreencast before calling stopScreencast'); 178 | } 179 | self.screenRecorderVideo = self.sr.stop(options); 180 | return self.sr.expectedResult(); 181 | } 182 | 183 | async addSubtitle(options) { 184 | const self = this; 185 | if (!self.sr) { 186 | throw new Error('must call startScreencast before calling addSubtitle'); 187 | } 188 | self.sr.addSubtitle(options); 189 | return {}; 190 | } 191 | 192 | async cleanBrowser() { 193 | const self = this; 194 | debug('start browser cleanup'); 195 | try { 196 | const { 197 | Network, Target, Page, Storage, 198 | } = await self.getConn(); 199 | 200 | const closeTarget = function closeTarget(target) { 201 | return Target.activateTarget(target).then(() => Target.closeTarget(target)); 202 | }; 203 | 204 | const clearData = [Network.clearBrowserCookies()]; 205 | self.chromeStorage.forEach((e) => { 206 | clearData.push(Storage.clearDataForOrigin(e)); 207 | }); 208 | await Promise.all(clearData); 209 | await Page.navigate({ url: 'about:blank' }); 210 | const result = await Target.getTargets(); 211 | 212 | let p = Promise.resolve(); 213 | result.targetInfos.forEach((target) => { 214 | // close everything but the main target 215 | if (target.targetId !== self.target) { 216 | p = p.then(() => closeTarget(target)); 217 | } 218 | }); 219 | await p; 220 | } catch (err) { 221 | debug(`Browser cleanup error: ${err}`); 222 | throw err; 223 | } 224 | } 225 | 226 | async stop() { 227 | const self = this; 228 | debug('stop agent'); 229 | if (typeof self.screenRecorderVideo !== 'undefined') { 230 | await self.screenRecorderVideo.then(() => self.sr.s3UploadResult); 231 | } 232 | if (self.client) { 233 | await self.removeAllScripts(); 234 | await self.cleanBrowser(); 235 | self.client.close(); 236 | } 237 | } 238 | 239 | handle(blob) { 240 | const self = this; 241 | try { 242 | if (blob.action === 'stop') { 243 | return self.stop().then(() => { 244 | debug('stop agent success'); 245 | process.exit(0); 246 | }).catch((err) => { 247 | debug(`error when stopping agent: ${err}`); 248 | process.exit(1); 249 | }); 250 | } 251 | const { value } = blob; 252 | const { path, sessionId, httpMethod } = value; 253 | const body = value.body ? JSON.parse(blob.value.body) : {}; 254 | body.sessionId = sessionId; 255 | 256 | let action = null; 257 | const route = Object.values(self.routes).find((r) => { 258 | if (httpMethod === r[0] && path === r[1]) { 259 | return true; 260 | } 261 | return false; 262 | }); 263 | action = route && route[2]; 264 | if (action === null) { 265 | return Promise.reject(new Error(`unknown path: ${httpMethod} ${path}`)); 266 | } 267 | if (typeof (self[action]) !== 'function') { 268 | return Promise.reject(new Error(`undefined method: ${action}`)); 269 | } 270 | return self[action](body); 271 | } catch (err) { 272 | return Promise.reject(new Error(`unknown error: ${err}`)); 273 | } 274 | } 275 | } 276 | 277 | 278 | module.exports = ChromeAgent; 279 | -------------------------------------------------------------------------------- /lib/realtime_screen_recorder.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const CDP = require('chrome-remote-interface'); 26 | const fs = require('fs'); 27 | const cp = require('child_process'); 28 | const util = require('util'); 29 | const debug = require('debug')('chromedriver_proxy:realtime_screen_recorder'); 30 | const S3 = require('aws-sdk/clients/s3'); 31 | const path = require('path'); 32 | 33 | const readFile = util.promisify(fs.readFile); 34 | 35 | const defaultVideoFormatArgs = { 36 | m3u8: [ 37 | '-an', 38 | '-g', 39 | '10', 40 | '-c:v', 41 | 'libx264', 42 | '-f', 43 | 'hls', 44 | '-flags', 45 | '-global_header', 46 | '-hls_time', 47 | '3', 48 | '-hls_list_size', 49 | '0', 50 | '-hls_playlist_type', 51 | 'event', 52 | '-y', 53 | ], 54 | }; 55 | 56 | class ScreenRecorder { 57 | constructor(options) { 58 | this.videoFormat = options.videoFormat || 'm3u8'; 59 | this.extraArgs = options.extraArgs || defaultVideoFormatArgs[this.videoFormat] || []; 60 | this.tmpDir = options.tmpDir || '/tmp'; 61 | this.ffmpegPath = options.ffmpegPath || '/usr/bin/ffmpeg'; 62 | this.s3Options = options.s3 || {}; 63 | this.s3UploadOptions = options.s3Upload || {}; 64 | this.client = options.client; 65 | this.index = 0; 66 | this.end = false; 67 | this.segments = null; 68 | this.segmentsMeta = []; 69 | this.uploadedSegments = new Set(); 70 | } 71 | 72 | static create(options) { 73 | return new Promise((resolve, reject) => { 74 | CDP({ host: '127.0.0.1', port: options.port, target: options.target }, (client) => { 75 | const screenRecorder = new ScreenRecorder({ 76 | client, 77 | tmpDir: options.tmpDir, 78 | ffmpegPath: options.ffmpegPath, 79 | videoFormat: options.videoFormat, 80 | extraArgs: options.extraArgs, 81 | }); 82 | const { Page } = client; 83 | Page.enable().then(() => { 84 | resolve(screenRecorder); 85 | }); 86 | }).on('error', (err) => { 87 | debug('reject on connecting'); 88 | reject(err); 89 | }); 90 | }); 91 | } 92 | 93 | async start(options) { 94 | const self = this; 95 | const params = options.params || {}; 96 | const format = params.format || 'png'; 97 | self.s3Prefix = options.s3Prefix || ''; 98 | self.sessionId = options.sessionId; 99 | self.dir = options.dir || self.tmpDir; 100 | const { Page } = self.client; 101 | self.frames = []; 102 | 103 | const output = self.getMetaPath(); 104 | self.output = output; 105 | const touchOutput = new Promise((resolve, reject) => { 106 | fs.open(output, 'w', (err) => { 107 | if (err) { 108 | reject(err); 109 | } else { 110 | resolve(); 111 | } 112 | }); 113 | }); 114 | await touchOutput; 115 | debug(`output: ${output}`); 116 | fs.watchFile(output, async () => { 117 | debug(`file change: ${output}`); 118 | await self.updateStream({ path: output }); 119 | }); 120 | self.ffmpegChild = self.streamImagesToVideo({ 121 | output, 122 | imgFormat: format, 123 | }); 124 | const errors = []; 125 | const ffmpegErrorListener = (data) => { 126 | errors.push(data); 127 | }; 128 | self.ffmpegChild.stderr.addListener('data', ffmpegErrorListener); 129 | self.ffmpegChild.on('close', (code) => { 130 | self.ffmpegChild.stderr.removeListener('data', ffmpegErrorListener); 131 | if (code !== 0) { 132 | debug(errors.join('\n')); 133 | } 134 | }); 135 | self.ffmpegChild.stdin.on('error', (err) => { 136 | debug(err); 137 | }); 138 | 139 | 140 | // register listener 1st 141 | self.client.on('Page.screencastFrame', (result) => { 142 | Page.screencastFrameAck({ sessionId: result.sessionId }); 143 | const binaryBlob = Buffer.from(result.data, 'base64'); 144 | self.ffmpegChild.stdin.write(binaryBlob); 145 | }); 146 | 147 | self.client.on('Page.screencastVisibilityChanged', (visible) => { 148 | debug(`visiblility changed: ${JSON.stringify(visible)}`); 149 | }); 150 | 151 | debug('start screencast'); 152 | return Page.startScreencast(params).catch((err) => { 153 | debug(err); 154 | }); 155 | } 156 | 157 | async updateStream(options) { 158 | const self = this; 159 | debug('begin update stream'); 160 | const data = await readFile(options.path); 161 | const newSegments = []; 162 | 163 | let segmentFlag = false; 164 | data.toString().split('\n').forEach((e) => { 165 | const absPath = `${this.tmpDir}/${e}`; 166 | if (segmentFlag) { 167 | if (!self.uploadedSegments.has(absPath)) { 168 | newSegments.push(absPath); 169 | } 170 | segmentFlag = false; 171 | } 172 | if (e.startsWith('#EXTINF:')) { 173 | segmentFlag = true; 174 | } 175 | }); 176 | debug(`new segments: ${JSON.stringify(newSegments)}`); 177 | await self.uploadSegments(newSegments); 178 | const result = await self.uploadMeta(data); 179 | debug('end update stream'); 180 | return result; 181 | } 182 | 183 | async uploadSegments(newSegments) { 184 | const self = this; 185 | debug(`upload: ${JSON.stringify(newSegments)}`); 186 | newSegments.forEach(e => self.uploadedSegments.add(e)); 187 | await Promise.all(newSegments.map(s => self.uploadToS3(s))); 188 | } 189 | 190 | async uploadMeta(data) { 191 | const self = this; 192 | debug('upload meta'); 193 | return self.uploadToS3(data); 194 | } 195 | 196 | 197 | streamImagesToVideo(options) { 198 | const self = this; 199 | const ffmpegExe = this.ffmpegPath; 200 | const args = [ 201 | '-f', 202 | 'image2pipe', 203 | '-i', 204 | `pipe:.${options.imgFormat}`, 205 | ].concat(self.extraArgs).concat(options.extraArgs || []); 206 | args.push(options.output); 207 | 208 | debug(`${ffmpegExe} ${args.join(' ')}`); 209 | 210 | return cp.spawn(ffmpegExe, args); 211 | } 212 | 213 | getSegmentPath(index) { 214 | const self = this; 215 | return path.join(self.tmpDir, self.getSegmentName(index)); 216 | } 217 | 218 | getMetaPath() { 219 | const self = this; 220 | return path.join(self.tmpDir, self.getMetaName()); 221 | } 222 | 223 | getMetaName() { 224 | const self = this; 225 | return `${self.sessionId}.m3u8`; 226 | } 227 | 228 | getSegmentName(index) { 229 | const self = this; 230 | return `${self.sessionId}${index}.ts`; 231 | } 232 | 233 | getFilePath() { 234 | const self = this; 235 | return path.join(self.tmpDir, self.getMetaName()); 236 | } 237 | 238 | getFileName() { 239 | const self = this; 240 | return `${self.sessionId}.${self.videoFormat}`; 241 | } 242 | 243 | getS3Key(name) { 244 | const self = this; 245 | return `${self.s3Prefix || ''}${name || self.getFileName()}`; 246 | } 247 | 248 | uploadToS3(file) { 249 | const self = this; 250 | let key; 251 | let ext; 252 | if (Buffer.isBuffer(file)) { 253 | key = self.getS3Key(self.getMetaName()); 254 | ext = 'm3u8'; 255 | } else { 256 | key = self.getS3Key(path.basename(file)); 257 | ext = path.extname(file).slice(1); 258 | } 259 | 260 | if (!this.s3UploadOptions.Bucket) { 261 | debug('skipping s3 upload: Bucket has not been set'); 262 | return Promise.resolve(); 263 | } 264 | 265 | return new Promise((resolve, reject) => { 266 | try { 267 | const s3 = new S3(self.s3Options); 268 | const stream = Buffer.isBuffer(file) ? file : fs.createReadStream(file); 269 | 270 | const params = { 271 | ContentType: `video/${ext}`, 272 | Key: key, 273 | Body: stream, 274 | }; 275 | Object.assign(params, self.s3UploadOptions); 276 | 277 | debug('start file upload'); 278 | s3.upload(params, (err) => { 279 | if (err) { 280 | debug(`s3 upload error: ${err}`); 281 | reject(err); 282 | } else { 283 | resolve({ bucket: self.s3UploadOptions.Bucket, key }); 284 | debug(`successfully uploaded ${key} to ${self.s3UploadOptions.Bucket}`); 285 | } 286 | }); 287 | } catch (err) { 288 | debug(`s3 upload error: ${err}`); 289 | reject(err); 290 | } 291 | }); 292 | } 293 | 294 | expectedResult() { 295 | const self = this; 296 | return { 297 | path: self.getFilePath(), 298 | s3: { 299 | bucket: self.s3UploadOptions.Bucket, 300 | key: self.getS3Key(), 301 | }, 302 | }; 303 | } 304 | 305 | async stop() { 306 | const self = this; 307 | self.end = true; 308 | const { Page } = self.client; 309 | try { 310 | debug('stop screencast'); 311 | self.client.removeAllListeners('Page.screencastVisibilityChanged'); 312 | self.client.removeAllListeners('Page.screencastFrame'); 313 | await Page.stopScreencast(); 314 | const waitForFFmpeg = new Promise((resolve) => { 315 | self.ffmpegChild.on('close', () => { 316 | resolve(); 317 | }); 318 | }); 319 | self.ffmpegChild.stdin.end(); 320 | await waitForFFmpeg; 321 | await self.updateStream({ path: self.output }); 322 | } catch (err) { 323 | debug(`error in screencast cleanup: ${err}`); 324 | } 325 | return self.expectedResult; 326 | } 327 | } 328 | 329 | module.exports = ScreenRecorder; 330 | -------------------------------------------------------------------------------- /lib/proxy.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const http = require('http'); 26 | const httpProxy = require('http-proxy'); 27 | const ChromeDriver = require('./chromedriver.js'); 28 | const ChromePool = require('./chrome_pool.js'); 29 | const debug = require('debug')('chromedriver_proxy:proxy'); 30 | 31 | class HttpServer { 32 | constructor(config) { 33 | const c = config || {}; 34 | this.port = c.port || 4444; 35 | this.baseUrl = c.baseUrl || null; 36 | if (this.baseUrl !== null && !this.baseUrl.startsWith('/')) { 37 | this.baseUrl = `/${this.baseUrl}`; 38 | } 39 | if (this.baseUrl !== null && this.baseUrl.endsWith('/')) { 40 | this.baseUrl = this.baseUrl.substring(0, this.baseUrl.length - 1); 41 | } 42 | if (this.baseUrl !== null) { 43 | this.endBaseUrl = this.baseUrl.length; 44 | } 45 | } 46 | 47 | start(opts, fn) { 48 | const options = opts || {}; 49 | const self = this; 50 | const timeout = options.timeout || -1; 51 | const keepAliveTimeout = options.keepAliveTimeout || 0; 52 | 53 | options.chromedriver = options.chromedriver || {}; 54 | options.chromePool = options.chromePool || {}; 55 | options.screenRecorder = options.chromePool.screenRecorder || {}; 56 | 57 | const tmpDir = options.tmpDir || '/tmp'; 58 | options.chromedriver.tmpDir = options.chromedriver.tmpDir || tmpDir; 59 | options.chromePool.tmpDir = options.chromePool.tmpDir || tmpDir; 60 | options.screenRecorder.tmpDir = options.screenRecorder.tmpDir || tmpDir; 61 | 62 | 63 | self.chromedriver = new ChromeDriver(options.chromedriver); 64 | const chromedriverStart = self.chromedriver.start(); 65 | 66 | self.chromepool = new ChromePool(options.chromePool); 67 | const bypassChromePool = !self.chromepool.enable; 68 | 69 | self.activeSessions = {}; 70 | self.screenRecorders = {}; 71 | 72 | self.httpAgent = http.globalAgent; 73 | self.httpAgent.maxSockets = 10000; 74 | self.httpAgent.keepAlive = true; 75 | self.httpAgent.keepAliveMsecs = 30000; 76 | 77 | // 78 | // Create a proxy server with custom application logic 79 | // 80 | const proxyConfig = { 81 | agent: self.httpAgent, 82 | }; 83 | if (timeout !== -1) { 84 | proxyConfig.proxyTimeout = timeout; 85 | } 86 | const proxy = httpProxy.createProxyServer(proxyConfig); 87 | 88 | proxy.on('error', (err, req, res) => { 89 | res.writeHead(500, {}); 90 | const blob = JSON.stringify({ 91 | message: err.message, 92 | stack: err.stack, 93 | }); 94 | res.end(blob); 95 | debug(`proxy error: ${blob}`); 96 | }); 97 | 98 | 99 | if (!bypassChromePool) { 100 | proxy.on('proxyRes', (proxyRes, req) => { 101 | if (req.method === 'DELETE' && req.url.length === 41) { 102 | const sessionId = req.url.substring(9); 103 | const port = self.activeSessions[sessionId]; 104 | delete self.activeSessions[sessionId]; 105 | self.chromepool.put(port); 106 | debug(`Delete session: ${sessionId} at port: ${port}`); 107 | } 108 | }); 109 | } 110 | 111 | const chromedriverEndpoint = `http://127.0.0.1:${self.chromedriver.port}`; 112 | 113 | // 114 | // Create your custom server and just call `proxy.web()` to proxy 115 | // a web request to the target passed in the options 116 | // also you can use `proxy.ws()` to proxy a websockets request 117 | // 118 | self.server = http.createServer((req, res) => { 119 | if (timeout !== -1) { 120 | res.setTimeout(timeout, () => { 121 | res.write(JSON.stringify({ value: { error: 'TIMEOUT', message: 'proxy timeout', stacktrace: '' } })); 122 | res.end(); 123 | }); 124 | } 125 | if (self.baseUrl !== null) { 126 | req.url = req.url.substring(self.endBaseUrl); 127 | } 128 | if (bypassChromePool) { 129 | proxy.web(req, res, { target: chromedriverEndpoint }); 130 | return; 131 | } 132 | 133 | const path = req.url.slice(42); 134 | if (req.url === '/session' && req.method === 'POST') { 135 | let body = ''; 136 | req.on('data', (chunk) => { 137 | body += chunk; 138 | }); 139 | 140 | req.on('end', () => { 141 | const caps = JSON.parse(body); 142 | let chromeOptions = {}; 143 | if (caps.desiredCapabilities) { 144 | chromeOptions = caps.desiredCapabilities.chromeOptions || caps.desiredCapabilities['goog:chromeOptions']; 145 | } else if (caps.capabilities.alwaysMatch) { 146 | chromeOptions = caps.capabilities.alwaysMatch['goog:chromeOptions']; 147 | } 148 | self.chromepool.get({ args: chromeOptions.args }) 149 | .then(port => self.chromepool.getAgent({ port }).then(() => port)).then((port) => { 150 | chromeOptions.debuggerAddress = `localhost:${port}`; 151 | if (caps.capabilities !== undefined && caps.capabilities.alwaysMatch !== undefined) { 152 | caps.capabilities.alwaysMatch['goog:chromeOptions'].debuggerAddress = chromeOptions.debuggerAddress; 153 | } 154 | const nbody = JSON.stringify(caps); 155 | debug(`capabilities: ${nbody}`); 156 | req.headers['Content-Length'] = Buffer.byteLength(nbody); 157 | 158 | const proxiedReq = http.request({ 159 | port: 4445, 160 | method: req.method, 161 | path: req.url, 162 | headers: req.headers, 163 | agent: self.httpAgent, 164 | timeout: 2000, 165 | hostname: 'localhost', 166 | }, (resp) => { 167 | res.writeHead(resp.statusCode, resp.headers); 168 | let sessionBlob = ''; 169 | resp.on('data', (chunk) => { 170 | sessionBlob += chunk; 171 | res.write(chunk); 172 | }); 173 | resp.on('end', () => { 174 | const sessionInfo = JSON.parse(sessionBlob); 175 | if (sessionInfo.status === 0) { 176 | const { sessionId } = sessionInfo; 177 | self.activeSessions[sessionId] = port; 178 | debug(`Started session: ${sessionId} at port: ${port}`); 179 | } else if (sessionInfo.value) { 180 | const { sessionId } = sessionInfo.value; 181 | self.activeSessions[sessionId] = port; 182 | debug(`Started session: ${sessionId} at port: ${port}`); 183 | } else { 184 | debug(`Error: ${sessionBlob}`); 185 | // ahhhhhhhhhhhh something broke!!! ... 186 | } 187 | res.end(); 188 | }); 189 | }); 190 | 191 | proxiedReq.on('error', (err) => { 192 | debug(err); 193 | res.writeHead(500); 194 | res.end(); 195 | }); 196 | 197 | proxiedReq.write(nbody); 198 | proxiedReq.end(); 199 | }).catch((err) => { 200 | debug(err); 201 | res.writeHead(500); 202 | res.end(err); 203 | }); 204 | }); 205 | } else if (path.slice(0, 18) === 'chromedriver-proxy') { 206 | const sessionId = req.url.substring(9, 41); 207 | const body = []; 208 | const port = self.activeSessions[sessionId]; 209 | req.on('data', (chunk) => { body.push(chunk); }); 210 | req.on('end', () => { 211 | debug(`start proxy request to chrome agent port: ${port} path: ${req.url}`); 212 | self.chromepool.sendToAgent({ 213 | action: 'req', 214 | port, 215 | value: { 216 | path: path.slice(19), 217 | sessionId, 218 | httpMethod: req.method, 219 | body: Buffer.concat(body).toString(), 220 | }, 221 | }).then((result) => { 222 | res.write(JSON.stringify({ 223 | status: 0, 224 | value: result, 225 | })); 226 | res.end(); 227 | debug(`end proxy request to chrome agent port: ${port} path: ${req.url}`); 228 | }).catch((err) => { 229 | res.writeHead(500); 230 | res.end(); 231 | debug(`error proxy request to chrome agent port: ${port} path: ${req.url} error: ${err}`); 232 | }); 233 | }); 234 | } else { 235 | // You can define here your custom logic to handle the request 236 | // and then proxy the request. 237 | proxy.web(req, res, { target: chromedriverEndpoint }); 238 | } 239 | }); 240 | 241 | self.server.keepAliveTimeout = keepAliveTimeout; 242 | 243 | self.server.listen(self.port, () => { 244 | chromedriverStart.then(() => { 245 | if (fn) { 246 | fn(); 247 | } 248 | debug(`started proxy server at port: ${self.port}`); 249 | }).catch((err) => { 250 | console.error(`FATAL UNABLE TO START CHROMEDRIVER: ${err}`); 251 | process.exit(1); 252 | }); 253 | }); 254 | } 255 | 256 | stop(fn) { 257 | const self = this; 258 | debug('start shutdown'); 259 | self.httpAgent.destroy(); 260 | debug('stop proxy'); 261 | self.server.close(() => { 262 | debug('server shutdown'); 263 | self.chromedriver.stop().then(() => { 264 | debug('chromedriver shutdown'); 265 | self.chromepool.killAll(); 266 | if (fn) { 267 | fn(); 268 | } 269 | }); 270 | }); 271 | } 272 | } 273 | 274 | module.exports = HttpServer; 275 | -------------------------------------------------------------------------------- /lib/screen_recorder.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Copyright (c) 2017 ZipRecruiter 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * */ 24 | 25 | const CDP = require('chrome-remote-interface'); 26 | const fs = require('fs'); 27 | const cp = require('child_process'); 28 | const util = require('util'); 29 | const debug = require('debug')('chromedriver_proxy:screen_recorder'); 30 | const debugFrame = require('debug')('chromedriver_proxy:screen_recorder_frame'); 31 | const S3 = require('aws-sdk/clients/s3'); 32 | const path = require('path'); 33 | 34 | const writeFile = util.promisify(fs.writeFile); 35 | const rm = util.promisify(fs.unlink); 36 | 37 | const defaultVideoFormatArgs = { 38 | webm: [ 39 | '-crf', 40 | '30', 41 | '-minrate', 42 | '500k', 43 | '-b:v', 44 | '2000k', 45 | '-c:v', 46 | 'libvpx-vp9', 47 | ], 48 | mp4: [ 49 | '-crf', 50 | '30', 51 | '-minrate', 52 | '500k', 53 | '-maxrate', 54 | '2000k', 55 | '-c:v', 56 | 'libx264', 57 | '-pix_fmt', 58 | 'yuv420p', 59 | ], 60 | }; 61 | 62 | 63 | function vttTime(timestamp) { 64 | let t = timestamp; 65 | if (t === 0) { 66 | return '00:00.000'; 67 | } 68 | // convert minutes 69 | let minutes = '00'; 70 | const actualMinutes = Math.floor(t / 60); 71 | if (actualMinutes > 10) { 72 | minutes = `${actualMinutes}`; 73 | } else if (actualMinutes >= 1) { 74 | minutes = `0${actualMinutes}`; 75 | } 76 | if (actualMinutes >= 1) { 77 | t -= (60 * actualMinutes); 78 | } 79 | 80 | let seconds = '00'; 81 | // convert seconds 82 | if (t > 9) { 83 | seconds = `${Number.parseFloat(t).toFixed(3)}`; 84 | } else if (t > 0) { 85 | seconds = `0${Number.parseFloat(t).toFixed(3)}`; 86 | } 87 | 88 | return `${minutes}:${seconds}`; 89 | } 90 | 91 | class ScreenRecorder { 92 | constructor(options) { 93 | this.videoFormat = options.videoFormat || 'webm'; 94 | this.extraArgs = options.extraArgs || defaultVideoFormatArgs[this.videoFormat] || []; 95 | this.tmpDir = options.tmpDir || '/tmp'; 96 | this.ffmpegPath = options.ffmpegPath || '/usr/bin/ffmpeg'; 97 | this.s3Options = options.s3 || {}; 98 | this.s3UploadOptions = options.s3Upload || {}; 99 | this.client = options.client; 100 | this.index = 0; 101 | this.subtitles = []; 102 | this.firstFrameTimestamp = null; 103 | this.currentDuration = 0; 104 | } 105 | 106 | static create(options) { 107 | return new Promise((resolve, reject) => { 108 | CDP({ host: '127.0.0.1', port: options.port, target: options.target }, (client) => { 109 | const screenRecorder = new ScreenRecorder({ 110 | client, 111 | tmpDir: options.tmpDir, 112 | ffmpegPath: options.ffmpegPath, 113 | videoFormat: options.videoFormat, 114 | extraArgs: options.extraArgs, 115 | }); 116 | const { Page } = client; 117 | Page.enable().then(() => { 118 | resolve(screenRecorder); 119 | }); 120 | }).on('error', (err) => { 121 | debug('reject on connecting'); 122 | reject(err); 123 | }); 124 | }); 125 | } 126 | 127 | start(options) { 128 | const self = this; 129 | const params = options.params || {}; 130 | const format = params.format || 'png'; 131 | self.s3Prefix = options.s3Prefix || ''; 132 | self.sessionId = options.sessionId; 133 | self.s3FileName = options.s3FileName || `${self.sessionId}.${self.videoFormat}`; 134 | self.s3SubsFileName = options.s3SubtitleFileName || `${self.sessionId}.vtt`; 135 | self.dir = options.dir || self.tmpDir; 136 | const { Page } = self.client; 137 | self.frames = []; 138 | 139 | // register listener 1st 140 | self.client.on('Page.screencastFrame', (result) => { 141 | Page.screencastFrameAck({ sessionId: result.sessionId }); 142 | const binaryBlob = Buffer.from(result.data, 'base64'); 143 | const framePath = `${self.dir}/${self.sessionId}-${self.index}.${format}`; 144 | self.frames.push({ file: framePath, timestamp: result.metadata.timestamp }); 145 | debugFrame(`screencast frame: ${framePath} frame id: ${result.sessionId} time: ${result.metadata.timestamp}`); 146 | if (self.firstFrameTimestamp === null) { 147 | self.firstFrameTimestamp = result.metadata.timestamp; 148 | } 149 | self.currentDuration = result.metadata.timestamp - self.firstFrameTimestamp; 150 | self.index += 1; 151 | fs.writeFile(framePath, binaryBlob, (err) => { 152 | if (err) { 153 | debug(`error unable to write screen frame to file: ${err}`); 154 | } 155 | }); 156 | }); 157 | 158 | self.client.on('Page.screencastVisibilityChanged', (visible) => { 159 | debug(`visiblility changed: ${JSON.stringify(visible)}`); 160 | }); 161 | 162 | debug('start screencast'); 163 | return Page.startScreencast(params).catch((err) => { 164 | debug(err); 165 | }); 166 | } 167 | 168 | addSubtitle(options) { 169 | const self = this; 170 | const text = options.text || ''; 171 | const subtitle = { 172 | text, 173 | timestamp: self.currentDuration, 174 | }; 175 | debug(subtitle); 176 | self.subtitles.push(subtitle); 177 | } 178 | 179 | createSubtitle() { 180 | const self = this; 181 | if (!self.subtitles.length) { 182 | return null; 183 | } 184 | 185 | // create webvtt formatted subtitle string 186 | let b = 'WEBVTT\n\n'; 187 | let lastTime = vttTime(self.subtitles[0].timestamp); 188 | let subtitle = self.subtitles[0]; 189 | 190 | for (let i = 1; i < self.subtitles.length; i += 1) { 191 | const nextTime = vttTime(self.subtitles[i].timestamp); 192 | if (nextTime !== '00:00.000') { 193 | b += `${lastTime} --> ${nextTime}\n`; 194 | b += `${subtitle.text}\n\n`; 195 | } 196 | lastTime = nextTime; 197 | subtitle = self.subtitles[i]; 198 | } 199 | 200 | b += `${lastTime} --> ${vttTime(self.currentDuration + 30)}\n`; 201 | b += `${subtitle.text}\n\n`; 202 | 203 | return { 204 | path: path.join(self.tmpDir, `${self.sessionId}-video.vtt`), 205 | blob: b, 206 | }; 207 | } 208 | 209 | imagesToVideo(options) { 210 | const self = this; 211 | const ffmpegExe = '/usr/bin/ffmpeg'; 212 | const args = [ 213 | '-safe', 214 | '0', 215 | '-f', 216 | 'concat', 217 | '-i', 218 | options.input, 219 | ].concat(self.extraArgs); 220 | args.push(options.output); 221 | 222 | debug(`${ffmpegExe} ${args.join(' ')}`); 223 | 224 | const ffmpegChild = cp.spawn(ffmpegExe, args); 225 | 226 | return new Promise((resolve, reject) => { 227 | const errors = []; 228 | const ffmpegErrorListener = (data) => { 229 | errors.push(data); 230 | }; 231 | ffmpegChild.stderr.addListener('data', ffmpegErrorListener); 232 | ffmpegChild.on('close', (code) => { 233 | ffmpegChild.stderr.removeListener('data', ffmpegErrorListener); 234 | if (code === 0) { 235 | resolve(0); 236 | } else { 237 | reject(errors.join('\n')); 238 | } 239 | }); 240 | }); 241 | } 242 | 243 | expectedResult() { 244 | const self = this; 245 | return { 246 | path: self.getFilePath(), 247 | s3: { 248 | bucket: self.s3UploadOptions.Bucket, 249 | key: self.getS3Key(), 250 | subtitlesKey: self.getS3SubsKey(), 251 | }, 252 | }; 253 | } 254 | 255 | getFilePath() { 256 | const self = this; 257 | return path.join(self.tmpDir, self.getFileName()); 258 | } 259 | 260 | getFileName() { 261 | const self = this; 262 | return `${self.sessionId}.${self.videoFormat}`; 263 | } 264 | 265 | getS3Key() { 266 | const self = this; 267 | return `${self.s3Prefix || ''}${self.s3FileName}`; 268 | } 269 | 270 | getS3SubsKey() { 271 | const self = this; 272 | return `${self.s3Prefix || ''}${self.s3SubsFileName}`; 273 | } 274 | 275 | uploadToS3(options) { 276 | const self = this; 277 | if (!this.s3UploadOptions.Bucket) { 278 | debug('skipping s3 upload: Bucket has not been set'); 279 | return Promise.resolve(); 280 | } 281 | 282 | const { file } = options; 283 | const { s3Key } = options; 284 | 285 | return new Promise((resolve, reject) => { 286 | try { 287 | const key = s3Key; 288 | const ext = path.extname(file).slice(1); 289 | 290 | const s3 = new S3(self.s3Options); 291 | const stream = fs.createReadStream(file); 292 | 293 | const params = { 294 | ContentType: `video/${ext}`, 295 | Key: key, 296 | Body: stream, 297 | }; 298 | Object.assign(params, self.s3UploadOptions); 299 | 300 | debug('start file upload'); 301 | s3.upload(params, (err) => { 302 | if (err) { 303 | debug(`s3 upload error: ${err}`); 304 | reject(err); 305 | } else { 306 | resolve({ bucket: self.s3UploadOptions.Bucket, key }); 307 | debug(`successfully uploaded ${key} to ${self.s3UploadOptions.Bucket}`); 308 | } 309 | }); 310 | } catch (err) { 311 | debug(`s3 upload error: ${err}`); 312 | reject(err); 313 | } 314 | }); 315 | } 316 | 317 | async stop() { 318 | const self = this; 319 | const { Page } = self.client; 320 | let output; 321 | try { 322 | debug('stop screencast'); 323 | self.client.removeAllListeners('Page.screencastVisibilityChanged'); 324 | self.client.removeAllListeners('Page.screencastFrame'); 325 | await Page.stopScreencast(); 326 | let b = `file '${self.frames[0].file}'\n`; 327 | let lasttimestamp = self.frames[0].timestamp; 328 | let lastfile; 329 | 330 | for (let i = 1; i < self.frames.length; i += 1) { 331 | const e = self.frames[i]; 332 | b += `duration ${e.timestamp - lasttimestamp}\n`; 333 | b += `file '${e.file}'\n`; 334 | lasttimestamp = e.timestamp; 335 | lastfile = e.file; 336 | } 337 | // so we see the last frame 338 | b += 'duration 1\n'; 339 | b += `file ${lastfile}\n`; 340 | 341 | const ffmpegFile = path.join(self.tmpDir, `${self.sessionId}-ffmpeg.txt`); 342 | await writeFile(ffmpegFile, b); 343 | debug(`ffmpeg config file: ${ffmpegFile}`); 344 | output = self.getFilePath(); 345 | await self.imagesToVideo({ 346 | input: ffmpegFile, 347 | output, 348 | }); 349 | debug(`sucessfully created screencast: ${output}`); 350 | const subtitle = self.createSubtitle(); 351 | self.s3UploadResult = await self.uploadToS3({ file: output, s3Key: self.getS3Key() }) 352 | .catch(e => ({ error: e })); 353 | if (subtitle) { 354 | await writeFile(subtitle.path, subtitle.blob); 355 | await self.uploadToS3({ file: subtitle.path, s3Key: self.getS3SubsKey() }) 356 | .catch(e => ({ error: e })); 357 | } 358 | const cleanup = [ffmpegFile]; 359 | self.frames.forEach((frame) => { 360 | cleanup.push(rm(frame.file)); 361 | }); 362 | await Promise.all(cleanup); 363 | } catch (err) { 364 | debug(`error in screencast cleanup: ${err}`); 365 | return Promise.reject(err); 366 | } 367 | return output; 368 | } 369 | } 370 | 371 | module.exports = ScreenRecorder; 372 | --------------------------------------------------------------------------------