├── .editorconfig ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── package-lock.json ├── package.json ├── router.js ├── src ├── api.ts ├── app.ts ├── constants.ts ├── editor.ts ├── favicon.ico ├── index.css ├── index.html ├── index.ts ├── puppeteer.worker.ts ├── runner.ts ├── sessions.ts ├── settings.ts ├── storage.ts ├── types.ts └── util.ts ├── tracker.js ├── tsconfig.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '39 13 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built Assets 2 | node_modules/ 3 | static/ 4 | 5 | # MAC 6 | .DS_Store 7 | 8 | # Temp files 9 | *.log 10 | 11 | # Caches 12 | .sass-cache 13 | _site 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | puppeteer_skip_chromium_download=true 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.2 2 | - Dependency updates and webpack updates. 3 | # 1.0.0 4 | - Initial implementation 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | This work is dual-licensed under GPL-3.0 OR the browserless commercial license. 4 | You can choose between one of them if you use this work. 5 | 6 | `SPDX-License-Identifier: GPL-3.0-or-later OR Browserless Commercial License` 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Browserless Debugger 2 | 3 | This is the repository for the web-ui client of browserless. The application is written in TypeScript, and produces a static asset in the `static` directory once built. 4 | 5 | Currently this uses native DOM APIs for rendering, as well as the wonderful monaco editor library. 6 | 7 | ## Installation 8 | 9 | 1. Ensure that NodeJS and npm are installed in your system: `node -v` shouldn't error. 10 | 2. Clone this repo: `git clone https://github.com/browserless/debugger.git debugger && cd debugger` 11 | 3. `npm install` 12 | 4. `npm run build` for the production build 13 | 5. `npm run dev` for the live dev environment. You'll want to serve your static assets from `static` with another web-server (we use http-server). 14 | 15 | More coming soon... 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@browserless.io/debugger", 3 | "version": "2.3.1", 4 | "description": "The web-client for browserless/chrome debugging", 5 | "main": "static/index.js", 6 | "files": [ 7 | "static/*" 8 | ], 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "build-dev": "node ./node_modules/webpack/bin/webpack.js", 12 | "build": "node ./node_modules/webpack/bin/webpack.js --optimization-minimize --mode production", 13 | "dev": "npm run build-dev -- --watch", 14 | "serve": "npm run build && http-server -a localhost ./static" 15 | }, 16 | "keywords": [ 17 | "browserless", 18 | "debugger", 19 | "puppeteer" 20 | ], 21 | "author": "joelgriffith", 22 | "license": "GPL-3.0-or-later OR Browserless Commercial License", 23 | "devDependencies": { 24 | "@types/file-saver": "^2.0.3", 25 | "buffer": "^6.0.3", 26 | "copy-webpack-plugin": "^9.1.0", 27 | "css-loader": "^6.4.0", 28 | "file-loader": "^6.1.0", 29 | "file-saver": "^2.0.5", 30 | "file-type": "^16.5.3", 31 | "html-webpack-plugin": "^5.3.2", 32 | "jszip": "^3.10.1", 33 | "monaco-editor": "^0.29.1", 34 | "monaco-editor-webpack-plugin": "^5.0.0", 35 | "puppeteer-core": "^23.6.0", 36 | "raw-loader": "^4.0.2", 37 | "style-loader": "^3.3.0", 38 | "ts-loader": "^9.2.6", 39 | "typescript": "^4.4.4", 40 | "webpack": "^5.58.2", 41 | "webpack-cli": "^4.9.0", 42 | "webpack-dev-server": "^4.3.1" 43 | }, 44 | "dependencies": { 45 | "http-server": "^14.1.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /router.js: -------------------------------------------------------------------------------- 1 | let path = location.pathname; 2 | 3 | // remove 'index.html' 4 | if (path.endsWith('index.html')) { 5 | path = path.substring(0, path.length - 'index.html'.length); 6 | } 7 | 8 | // Always have a trailing slash 9 | if (!path.endsWith('/')) { 10 | path += '/'; 11 | } 12 | 13 | // Build the new URL with the original query string and hash 14 | const newUrl = location.origin + path + location.search + location.hash; 15 | 16 | // Only use pushState if the new URL is different to avoid unnecessary history entries 17 | if (newUrl !== window.location.href) { 18 | window.history.pushState({}, '', newUrl); 19 | } 20 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { get, set } from "./storage"; 2 | import { splitByWhitespace } from "./util"; 3 | 4 | const apiSettingsKey = "apiSettings"; 5 | interface APIState { 6 | baseURL: string; 7 | headless: boolean; 8 | stealth: boolean; 9 | blockAds: boolean; 10 | ignoreHTTPSErrors: boolean; 11 | quality: number; 12 | } 13 | 14 | const getState = () => get(apiSettingsKey) as APIState; 15 | const saveState = (newState: Partial) => 16 | set(apiSettingsKey, { 17 | ...getState(), 18 | ...newState, 19 | }); 20 | 21 | const priorSettings = getState() ?? {}; 22 | 23 | const token = new URL(window.location.href).searchParams.get("token"); 24 | const defaultURL = new URL(window.location.origin); 25 | 26 | if (token) { 27 | defaultURL.searchParams.set('token', token); 28 | } 29 | 30 | const baseURL = priorSettings.baseURL ?? defaultURL.href; 31 | const headless = priorSettings.headless ?? true; 32 | const stealth = priorSettings.stealth ?? false; 33 | const blockAds = priorSettings.blockAds ?? false; 34 | const ignoreHTTPSErrors = priorSettings.ignoreHTTPSErrors ?? false; 35 | const quality = priorSettings.quality ?? 100; 36 | 37 | saveState({ 38 | ignoreHTTPSErrors, 39 | baseURL, 40 | headless, 41 | stealth, 42 | blockAds, 43 | quality, 44 | }); 45 | 46 | export const getHeadless = () => getState().headless; 47 | export const setHeadless = (headless: boolean) => saveState({ headless }); 48 | 49 | export const getStealth = () => getState().stealth; 50 | export const setStealth = (stealth: boolean) => saveState({ stealth }); 51 | 52 | export const getAds = () => getState().blockAds; 53 | export const setAds = (blockAds: boolean) => saveState({ blockAds }); 54 | 55 | export const getIgnoreHTTPS = () => getState().ignoreHTTPSErrors; 56 | export const setIgnoreHTTPS = (ignoreHTTPSErrors: boolean) => 57 | saveState({ ignoreHTTPSErrors }); 58 | 59 | export const getQuality = () => getState().quality; 60 | export const setQuality = (quality: number) => saveState({ quality }); 61 | 62 | export const getWebSocketURL = () => { 63 | const baseURL = getBaseURL(); 64 | const websocketURL = new URL(baseURL.href); 65 | websocketURL.protocol = websocketURL.protocol === "https:" ? "wss:" : "ws:"; 66 | 67 | return websocketURL; 68 | }; 69 | 70 | const devtoolsInspectorURL = "devtools/inspector.html"; 71 | const devtoolsAppURL = "devtools/devtools_app.html"; 72 | 73 | const getHostedApp = (targetId: string, path: string) => { 74 | const baseUrl = getBaseURL(); 75 | const isSecure = baseUrl.protocol === "https:"; 76 | const iframePageURL = `${isSecure ? "wss" : "ws"}=${baseUrl.host}${ 77 | baseUrl.pathname 78 | }devtools/page/${targetId}${baseUrl.search}`; 79 | 80 | return `${baseUrl.origin}${baseUrl.pathname}${path}${ 81 | baseUrl.search.length ? `${baseUrl.search}&` : "?" 82 | }${iframePageURL}`; 83 | }; 84 | 85 | export const getDevtoolsInspectorURL = (targetId: string) => { 86 | return getHostedApp(targetId, devtoolsInspectorURL); 87 | }; 88 | 89 | export const getDevtoolsAppURL = (targetId: string) => { 90 | return getHostedApp(targetId, devtoolsAppURL); 91 | }; 92 | 93 | export const getBaseURL = () => { 94 | const { baseURL } = get(apiSettingsKey); 95 | 96 | return new URL(baseURL); 97 | }; 98 | 99 | export const setBrowserURL = ( 100 | input: string, 101 | ): { valid: boolean; message: string } => { 102 | let response = { valid: false, message: "" }; 103 | 104 | if (!input.startsWith("ws")) { 105 | return { 106 | valid: false, 107 | message: "URL must start with ws:// or wss://", 108 | }; 109 | } 110 | 111 | try { 112 | new URL(input); 113 | response.valid = true; 114 | response.message = ""; 115 | } catch { 116 | response.valid = false; 117 | response.message = "Invalid browser URL"; 118 | } 119 | 120 | if (!input.startsWith("ws")) { 121 | response.valid = false; 122 | response.message = "URL must start with ws:// or wss://"; 123 | } 124 | 125 | if (response.valid) { 126 | const parsed = new URL(input); 127 | parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:"; 128 | 129 | saveState({ baseURL: parsed.href }); 130 | } 131 | 132 | return response; 133 | }; 134 | 135 | export const getConnectURL = () => { 136 | const wsURL = getWebSocketURL(); 137 | 138 | const headless = getHeadless(); 139 | const blockAds = getAds(); 140 | const ignoreHTTPSErrors = getIgnoreHTTPS(); 141 | const stealth = getStealth(); 142 | 143 | const launchArgs = JSON.stringify({ 144 | ignoreHTTPSErrors, 145 | stealth, 146 | args: splitByWhitespace((document.getElementById("chrome-flags") as any)?.value), 147 | }); 148 | 149 | if (blockAds) { 150 | wsURL.searchParams.append("blockAds", "true"); 151 | } 152 | 153 | if (!headless) { 154 | wsURL.searchParams.append("headless", "false"); 155 | } 156 | 157 | wsURL.searchParams.append("launch", launchArgs); 158 | return wsURL.href; 159 | }; 160 | 161 | export const fetchSessions = () => { 162 | const { baseURL } = get(apiSettingsKey); 163 | 164 | const sessionURL = new URL(baseURL); 165 | sessionURL.pathname = sessionURL.pathname + "sessions"; 166 | 167 | return fetch(sessionURL.href, { 168 | credentials: "same-origin", 169 | headers: { 170 | Accept: "application/json", 171 | }, 172 | }).then((res) => res.json()); 173 | }; 174 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip'; 2 | import { saveAs } from 'file-saver'; 3 | 4 | import { Editor } from './editor'; 5 | import { Runner } from './runner'; 6 | import { indexCode } from './constants'; 7 | import { Settings } from './settings'; 8 | import { Sessions } from './sessions'; 9 | 10 | const packageJSON = require('puppeteer-core/package.json'); 11 | 12 | const README = `# Browserless Starter-pack 13 | This simple starter-pack gets you up and running with all the code you used in the debugger. Just install and run! 14 | 15 | ## Requirements 16 | - NodeJS (version 12 or higher). 17 | - An environment to run command's (Terminal or others). 18 | 19 | ## Running 20 | 1. NodeJS >= 12 is installed 21 | 2. 'npm install' 22 | 3. 'npm start' 23 | `; 24 | 25 | const getPackageJson = () => `{ 26 | "name": "browserless-export", 27 | "description": "Exported package from browserless, ready to go!", 28 | "version": "1.0.0", 29 | "scripts": { 30 | "start": "node index.js" 31 | }, 32 | "dependencies": { 33 | "puppeteer-core": "${packageJSON.version}" 34 | } 35 | }`; 36 | 37 | export class App { 38 | private editor: Editor; 39 | private settings: Settings; 40 | private sessions: Sessions; 41 | 42 | private $editorButton = document.querySelector('#editor-button-radio') as HTMLInputElement; 43 | private $runButton = document.querySelector('#run-button') as HTMLElement; 44 | private $runnerMount = document.querySelector('#runner') as HTMLElement; 45 | private $editorPanel = document.querySelector('#editor') as HTMLElement; 46 | private $horizontalResizer = document.querySelector('#resize-main') as HTMLDivElement; 47 | private $download = document.querySelector('#download') as HTMLElement; 48 | private $radioButtons = [...(document.querySelectorAll('#side-nav input[type=radio]') as any)]; 49 | 50 | private runner: Runner | null; 51 | private offsetLeft = document.querySelector('#side-nav')?.getBoundingClientRect().width as number | 0; 52 | 53 | constructor ( 54 | { editor, settings, sessions }: 55 | { editor: Editor, settings: Settings, sessions: Sessions } 56 | ) { 57 | this.editor = editor; 58 | this.settings = settings; 59 | this.sessions = sessions; 60 | 61 | this.settings.onClose(this.onPanelClose); 62 | this.sessions.onClose(this.onPanelClose); 63 | 64 | this.$runButton.addEventListener('click', this.run); 65 | this.$download.addEventListener('click', this.download); 66 | this.$radioButtons.forEach((el) => el.addEventListener('change', this.onPanelChange)); 67 | 68 | this.addEventListeners(); 69 | } 70 | 71 | onPanelClose = () => { 72 | this.$editorButton.checked = true; 73 | this.onPanelChange(); 74 | }; 75 | 76 | onPanelChange = () => { 77 | const selectedPanel = document.querySelector('#side-nav input[type=radio]:checked') as HTMLInputElement; 78 | const openPanel = selectedPanel ? selectedPanel.value : 'editor'; 79 | 80 | if (openPanel === 'settings') { 81 | this.sessions.toggleVisibility(false); 82 | this.settings.toggleVisibility(true); 83 | } 84 | 85 | if (openPanel === 'sessions') { 86 | this.settings.toggleVisibility(false); 87 | this.sessions.toggleVisibility(true); 88 | } 89 | 90 | if (openPanel === 'editor') { 91 | this.settings.toggleVisibility(false); 92 | this.sessions.toggleVisibility(false); 93 | } 94 | }; 95 | 96 | addEventListeners = () => { 97 | this.$horizontalResizer.addEventListener('mousedown', this.onHorizontalResize); 98 | }; 99 | 100 | removeEventListeners = () => { 101 | this.$horizontalResizer.removeEventListener('mousedown', this.onHorizontalResize); 102 | }; 103 | 104 | download = async () => { 105 | const packageJson = getPackageJson(); 106 | const startJS = await this.editor.getCompiledCode(); 107 | const zip = new JSZip(); 108 | 109 | zip.file('index.js', indexCode); 110 | zip.file('start.js', startJS); 111 | zip.file('package.json', packageJson); 112 | zip.file('README.md', README); 113 | 114 | const content = await zip.generateAsync({ type: 'blob' }); 115 | saveAs(content, `browserless-project.zip`); 116 | }; 117 | 118 | onHorizontalResize = (evt: MouseEvent) => { 119 | evt.preventDefault(); 120 | 121 | this.$runnerMount.style.pointerEvents = 'none'; 122 | 123 | let onMouseMove: any = (moveEvent: MouseEvent) => { 124 | if (moveEvent.buttons === 0) return; 125 | 126 | const posX = moveEvent.clientX - this.offsetLeft; 127 | const fromRight = window.innerWidth - posX; 128 | 129 | this.$editorPanel.style.width = `${posX}px`; 130 | this.$runnerMount.style.width= `${fromRight}px`; 131 | }; 132 | 133 | let onMouseUp: any = () => { 134 | this.$runnerMount.style.pointerEvents = 'initial'; 135 | this.runner?.resizePage(); 136 | document.removeEventListener('mousemove', onMouseMove); 137 | document.removeEventListener('mouseup', onMouseUp); 138 | onMouseMove = null; 139 | onMouseUp = null; 140 | }; 141 | 142 | document.addEventListener('mousemove', onMouseMove); 143 | document.addEventListener('mouseup', onMouseUp); 144 | }; 145 | 146 | onRunnerComplete = (showMessage = true) => { 147 | this.runner = null; 148 | 149 | if (showMessage) { 150 | this.$runnerMount.innerHTML = ` 151 | ${this.$runnerMount.innerHTML} 152 |
153 | Session complete. Click ► to run your code again. 154 |
155 | `; 156 | } 157 | }; 158 | 159 | run = async () => { 160 | if (this.runner) { 161 | this.runner.close(false); 162 | } 163 | const code = await this.editor.getCompiledCode(); 164 | const $mount = this.$runnerMount; 165 | const onClose = this.onRunnerComplete; 166 | 167 | this.runner = new Runner({ code, $mount, onClose }); 168 | }; 169 | } 170 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const searchExample = `// Full TypeScript support for both puppeteer and the DOM 2 | export default async ({ page }: { page: Page }) => { 3 | 4 | // Full puppeteer API is available 5 | await page.goto('https://google.com/'); 6 | await page.type('textarea', 'browserless.io'); 7 | await Promise.all([ 8 | page.keyboard.press('Enter'), 9 | page.waitForNavigation(), 10 | ]); 11 | 12 | // Logs show up in the browser's devtools 13 | console.log(\`I show up in the page's console!\`); 14 | 15 | const topLinks = await page.evaluate(() => { 16 | const results = [...document.querySelectorAll('#search a')] as HTMLElement[]; 17 | return [...results].map(el => [el.innerText, el.getAttribute('href')]); 18 | }); 19 | 20 | // Can pause by injecting a "debugger;" statement. Uncomment to see the magic 21 | // await page.evaluate(() => { debugger; }); 22 | 23 | console.table(topLinks); 24 | };`; 25 | 26 | export const screenshotExample = `// Let's load up a cool dashboard and screenshot it! 27 | export default async ({ page }: { page: Page }) => { 28 | await page.goto('https://play.grafana.org/d/000000029/prometheus-demo-dashboard?orgId=1&refresh=5m&kiosk', { waitUntil: 'networkidle0'}); 29 | 30 | // Enlarge the viewport so we can capture it. 31 | await page.setViewport({ width: 1920, height: 1080 }); 32 | 33 | // Return the screenshot buffer which will trigger this editor to download it. 34 | return page.screenshot({ fullPage: true }); 35 | };`; 36 | 37 | export const pdfExample = `// For PDFs, let's take some API content and inject some simple styles 38 | export default async ({ page }: { page: Page }) => { 39 | 40 | // Let's get React's documentation and scrape out 41 | // their actual API docs without all the other stuff. 42 | await page.goto('https://reactjs.org/docs/react-api.html'); 43 | const apiContent = await page.evaluate(() => document.querySelector('article').innerHTML); 44 | 45 | // Now, let's get some simple markdown CSS for print 46 | await page.goto('https://raw.githubusercontent.com/simonlc/Markdown-CSS/master/markdown.css'); 47 | const stylesheet = await page.evaluate(() => document.body.innerText); 48 | 49 | // Finally, let's inject the above in a blank page and print it. 50 | await page.goto('about:blank'); 51 | await page.setContent(apiContent); 52 | await page.addStyleTag({ content: stylesheet }); 53 | 54 | // Return a PDF buffer to trigger the editor to download. 55 | return page.pdf(); 56 | };`; 57 | 58 | export const scrapeExample = `// In this example, we'll scrape links on HN 59 | export default async ({ page }: { page: Page }) => { 60 | await page.goto('https://news.ycombinator.com'); 61 | 62 | // Here, we inject some JavaScript into the page to build a list of results 63 | const items = await page.evaluate(() => { 64 | const elements = [...document.querySelectorAll('.athing a')]; 65 | const results = elements.map((el: HTMLAnchorElement) => ({ 66 | title: el.textContent, 67 | href: el.href, 68 | })); 69 | return JSON.stringify(results); 70 | }); 71 | 72 | // Finally, we return an object, which triggers a JSON file download 73 | return JSON.parse(items); 74 | };`; 75 | 76 | export const blankExample = `export default async ({ page }: { page: Page }) => { 77 | // Do Something with the page and return! 78 | // The editor will detect the return value, and either download 79 | // a JSON/PDF/PNG or Plain-text file. If you don't return 80 | // anything then nothing will happen. 81 | };`; 82 | 83 | export const indexCode = `const { default: start } = require('./start.js'); 84 | const puppeteer = require('puppeteer-core'); 85 | 86 | (async () => { 87 | const browser = await puppeteer.connect({ 88 | browserWSEndpoint: 'wss://chrome.browserless.io' 89 | }); 90 | const page = await browser.newPage(); 91 | 92 | await start({ page, browser }); 93 | 94 | return browser.close(); 95 | })() 96 | .then(() => console.log('Script complete!')) 97 | .catch((err) => console.error('Error running script' + err));`; 98 | -------------------------------------------------------------------------------- /src/editor.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from 'monaco-editor'; 2 | import { get, set } from './storage'; 3 | import { 4 | screenshotExample, 5 | pdfExample, 6 | scrapeExample, 7 | blankExample, 8 | searchExample, 9 | } from './constants'; 10 | 11 | const nodeTypes = (require as any).context('!!raw-loader!@types/node/', true, /\.d.ts$/); 12 | const puppeteerTypes = require('!!raw-loader!puppeteer-core/lib/types.d.ts'); 13 | 14 | interface tabs { 15 | tabName: string; 16 | code: string; 17 | active: boolean; 18 | } 19 | 20 | export class Editor { 21 | private editor: monaco.editor.IStandaloneCodeEditor; 22 | private $tabs = document.querySelector('#editor-tabs') as HTMLOListElement; 23 | 24 | static storageKey = 'editorTabs'; 25 | static closeButtonSelector = 'close-btn'; 26 | static dataTabCode = 'data-tab-code'; 27 | static activeTabClass = 'active'; 28 | static defaultTabs: Array = [{ 29 | tabName: 'Search', 30 | code: searchExample, 31 | active: true, 32 | },{ 33 | tabName: 'Scrape', 34 | code: scrapeExample, 35 | active: false, 36 | }, { 37 | tabName: 'PDF', 38 | code: pdfExample, 39 | active: false, 40 | }, { 41 | tabName: 'Screenshot', 42 | code: screenshotExample, 43 | active: false, 44 | }]; 45 | 46 | constructor($editor: HTMLElement) { 47 | const priorState = (get(Editor.storageKey) as tabs[] | null || Editor.defaultTabs); 48 | 49 | const editorCode: string = priorState.reduce((prev, { tabName, code, active }) => { 50 | const tab = document.createElement('li'); 51 | const closeButton = this.createCloseButton(); 52 | 53 | if (priorState.length === 1) { 54 | closeButton.setAttribute('disabled', 'true'); 55 | } 56 | 57 | tab.onclick = this.setTabActive; 58 | tab.className = active ? Editor.activeTabClass : ''; 59 | tab.innerHTML = tabName; 60 | tab.setAttribute(Editor.dataTabCode, code); 61 | tab.appendChild(closeButton); 62 | 63 | this.$tabs.appendChild(tab); 64 | 65 | return active ? code : prev; 66 | }, screenshotExample); 67 | 68 | const createTabEl = document.createElement('li'); 69 | createTabEl.className = `create`; 70 | createTabEl.onclick = this.addTab; 71 | 72 | this.$tabs.appendChild(createTabEl); 73 | 74 | this.setupEditor($editor, editorCode); 75 | }; 76 | 77 | saveState = () => { 78 | const tabs = this.getTabs(); 79 | const value = this.editor.getValue(); 80 | const activeTab = this.getActiveTab(); 81 | activeTab?.setAttribute(Editor.dataTabCode, value); 82 | 83 | const state = tabs.map((tab) => ({ 84 | tabName: tab.innerText.trim(), 85 | code: tab.getAttribute(Editor.dataTabCode), 86 | active: tab.className.includes(Editor.activeTabClass), 87 | })); 88 | 89 | set(Editor.storageKey, state); 90 | }; 91 | 92 | getActiveTab = () => document.querySelector(`#editor-tabs li.${Editor.activeTabClass}`); 93 | 94 | // @ts-ignore make array hack 95 | getTabs = (): HTMLElement[] => [...document.querySelectorAll('#editor-tabs li:not(.create)')]; 96 | 97 | clearActiveTabs = () => { 98 | const tabs = this.getTabs(); 99 | tabs.forEach(t => t.className = ''); 100 | }; 101 | 102 | createCloseButton = () => { 103 | const closeButton = document.createElement('button'); 104 | 105 | closeButton.className = Editor.closeButtonSelector; 106 | closeButton.onclick = this.removeTab; 107 | 108 | return closeButton; 109 | }; 110 | 111 | onAddTabComplete = (evt: FocusEvent) => { 112 | const tab = evt.target as HTMLElement; 113 | tab.contentEditable = 'false'; 114 | }; 115 | 116 | addTab = () => { 117 | const tabs = this.getTabs(); 118 | const tab = document.createElement('li'); 119 | const closeButton = this.createCloseButton(); 120 | tab.innerText = 'My-Script'; 121 | tab.contentEditable = 'true'; 122 | 123 | tab.onclick = this.setTabActive; 124 | tab.setAttribute(Editor.dataTabCode, blankExample); 125 | tab.className = Editor.activeTabClass; 126 | tab.onblur = this.onAddTabComplete; 127 | tab.appendChild(closeButton); 128 | 129 | tabs.forEach((t) => t.querySelector('.' + Editor.closeButtonSelector)?.removeAttribute('disabled')); 130 | this.clearActiveTabs(); 131 | this.$tabs.prepend(tab); 132 | this.editor.setValue(blankExample); 133 | tab.focus(); 134 | document.execCommand('selectAll', false); 135 | }; 136 | 137 | removeTab = (evt: MouseEvent) => { 138 | evt.stopPropagation(); 139 | const tab = (evt.target as HTMLElement).parentNode as HTMLElement; 140 | tab.parentElement?.removeChild(tab); 141 | 142 | const tabs = this.getTabs(); 143 | 144 | if (tabs.length === 1) { 145 | const [lastTab] = tabs; 146 | lastTab.querySelector('.' + Editor.closeButtonSelector)?.setAttribute('disabled', 'true'); 147 | } 148 | 149 | if (tab.className.includes(Editor.activeTabClass)) { 150 | const nextTab = tabs.find(t => t !== tab); 151 | 152 | if (nextTab) { 153 | nextTab.className = Editor.activeTabClass; 154 | this.editor.setValue(nextTab.getAttribute(Editor.dataTabCode) || screenshotExample); 155 | } 156 | return; 157 | } 158 | 159 | this.saveState(); 160 | }; 161 | 162 | setTabActive = (evt: MouseEvent) => { 163 | this.clearActiveTabs(); 164 | const tab = evt.target as HTMLElement; 165 | const code = tab.getAttribute(Editor.dataTabCode); 166 | tab.className = Editor.activeTabClass; 167 | this.editor.setValue(code || ''); 168 | }; 169 | 170 | public async getCompiledCode() { 171 | await new Promise((r) => setTimeout(r, 1000)); 172 | const model = this.editor.getModel(); 173 | if (!model) { 174 | throw new Error(`Couldn't successfully load editor's contents`); 175 | } 176 | const { uri } = model; 177 | const worker = await monaco.languages.typescript.getTypeScriptWorker(); 178 | const client = await worker(uri); 179 | const result = await client.getEmitOutput(uri.toString()); 180 | const [{ text }] = result.outputFiles; 181 | 182 | return text; 183 | } 184 | 185 | private setupEditor($editor: HTMLElement, initialCode: string) { 186 | // @ts-ignore 187 | self.MonacoEnvironment = { 188 | getWorkerUrl: (_moduleId: any, label: string) => { 189 | if (label === 'typescript' || label === 'javascript') { 190 | return './ts.worker.bundle.js'; 191 | } 192 | return './editor.worker.bundle.js'; 193 | } 194 | }; 195 | 196 | monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ 197 | allowNonTsExtensions: true, 198 | target: monaco.languages.typescript.ScriptTarget.ES2020, 199 | moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, 200 | module: monaco.languages.typescript.ModuleKind.CommonJS, 201 | }); 202 | 203 | nodeTypes.keys().forEach((key: string) => { 204 | monaco.languages.typescript.typescriptDefaults.addExtraLib( 205 | nodeTypes(key).default, 206 | 'node_modules/@types/node/' + key.substr(2) 207 | ); 208 | }); 209 | 210 | monaco.languages.typescript.typescriptDefaults.addExtraLib( 211 | puppeteerTypes.default 212 | .replace(/import.*/gi, '') 213 | .replace(/export /g, 'declare '), 214 | 'node_modules/@types/puppeteer/index.d.ts', 215 | ); 216 | 217 | monaco.editor.defineTheme('brwl', { 218 | base: 'vs-dark', 219 | inherit: true, 220 | rules: [ 221 | { 222 | token: "keyword", 223 | foreground: "00aaff" 224 | }, 225 | { 226 | token: "identifier.function", 227 | foreground: "dddddd" 228 | }, 229 | { 230 | token: "type", 231 | foreground: "fceca7" 232 | }, 233 | { 234 | token: "string", 235 | foreground: "ff9f64" 236 | } 237 | , 238 | { 239 | token: "number", 240 | foreground: "ff9f64" 241 | }, 242 | { 243 | token: "comment", 244 | foreground: "7c7c7c" 245 | } 246 | ], 247 | colors: {} 248 | }); 249 | 250 | this.editor = monaco.editor.create($editor, { 251 | value: initialCode, 252 | language: 'typescript', 253 | theme: 'brwl', 254 | fontSize: 16, 255 | wordWrap: 'on', 256 | scrollBeyondLastLine: false, 257 | automaticLayout: true, 258 | minimap: { 259 | enabled: false 260 | }, 261 | }); 262 | 263 | this.editor.onDidChangeModelContent(this.saveState); 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/browserless/debugger/a719d4663dd736dce9ad3edd4104f1ca9031774b/src/favicon.ico -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | :root { 7 | --tar-700: #222222; 8 | --charchoal-700: #454545; 9 | } 10 | 11 | * { 12 | font-family: Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace; 13 | } 14 | 15 | html, body, div, span, applet, object, iframe, 16 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 17 | a, abbr, acronym, address, big, cite, code, 18 | del, dfn, em, img, ins, kbd, q, s, samp, 19 | small, strike, strong, sub, sup, tt, var, 20 | b, u, i, center, 21 | dl, dt, dd, ol, ul, li, 22 | fieldset, form, label, legend, 23 | table, caption, tbody, tfoot, thead, tr, th, td, 24 | article, aside, canvas, details, embed, 25 | figure, figcaption, footer, header, hgroup, 26 | menu, nav, output, ruby, section, summary, 27 | time, mark, audio, video { 28 | margin: 0; 29 | padding: 0; 30 | border: 0; 31 | font-size: 100%; 32 | font: inherit; 33 | vertical-align: baseline; 34 | } 35 | /* HTML5 display-role reset for older browsers */ 36 | article, aside, details, figcaption, figure, 37 | footer, header, hgroup, menu, nav, section { 38 | display: block; 39 | } 40 | body { 41 | line-height: 1; 42 | } 43 | ol, ul { 44 | list-style: none; 45 | } 46 | blockquote, q { 47 | quotes: none; 48 | } 49 | blockquote:before, blockquote:after, 50 | q:before, q:after { 51 | content: ''; 52 | content: none; 53 | } 54 | table { 55 | border-collapse: collapse; 56 | border-spacing: 0; 57 | } 58 | 59 | html { 60 | color: #ccc; 61 | height: 100%; 62 | width: 100%; 63 | top: 0; 64 | left: 0; 65 | position: fixed; 66 | } 67 | 68 | body { 69 | width: 100%; 70 | height: 100%; 71 | border: 0; 72 | background: rgb(21, 21, 21); 73 | display: flex; 74 | flex-direction: column; 75 | } 76 | 77 | button { 78 | cursor: pointer; 79 | background: none; 80 | outline: none; 81 | border: none; 82 | color: white; 83 | } 84 | 85 | code { 86 | background: rgba(0,0,0,0.6); 87 | padding: 20px; 88 | } 89 | 90 | @keyframes pulse { 91 | 0% { 92 | transform: scale(0.95); 93 | box-shadow: 0 0 0 0 rgba(255, 156, 101, 0.7); 94 | } 95 | 96 | 70% { 97 | transform: scale(1); 98 | box-shadow: 0 0 0 10px rgba(255, 156, 101, 0); 99 | } 100 | 101 | 100% { 102 | transform: scale(0.95); 103 | box-shadow: 0 0 0 0 rgba(255, 156, 101, 0); 104 | } 105 | } 106 | 107 | header { 108 | height: 50px; 109 | padding: 10px 30px; 110 | border-bottom: 1px solid #333; 111 | display: flex; 112 | justify-content: space-between; 113 | flex-shrink: initial; 114 | align-items: center; 115 | } 116 | 117 | header .group { 118 | display: flex; 119 | align-items: center; 120 | } 121 | 122 | header .group > * { 123 | margin-left: 10px; 124 | } 125 | 126 | header .group > *:not(:last-child) { 127 | margin-right: 10px; 128 | } 129 | 130 | main { 131 | display: flex; 132 | flex: 1; 133 | flex-direction: row; 134 | } 135 | 136 | #editor { 137 | position: relative; 138 | height: 100%; 139 | width: 50%; 140 | } 141 | 142 | #editor-tabs { 143 | height: 35px; 144 | display: flex; 145 | flex-direction: row; 146 | overflow: scroll; 147 | flex-direction: row; 148 | } 149 | 150 | #editor-tabs li { 151 | cursor: pointer; 152 | padding-left: 10px; 153 | display: flex; 154 | background: #333; 155 | align-items: center; 156 | border-right: 1px solid rgb(36, 36, 36); 157 | background-color: rgb(21, 21, 21); 158 | min-width: 50px; 159 | } 160 | 161 | #editor-tabs li.create { 162 | margin-left: auto; 163 | width: 30px; 164 | background: url(data:image/svg+xml;base64,PHN2ZyBzdHJva2U9IndoaXRlIiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIwIiB2aWV3Qm94PSIwIDAgNDQ4IDUxMiIgaGVpZ2h0PSIxZW0iIHdpZHRoPSIxZW0iIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTI1NiA4MGMwLTE3LjctMTQuMy0zMi0zMi0zMnMtMzIgMTQuMy0zMiAzMlYyMjRINDhjLTE3LjcgMC0zMiAxNC4zLTMyIDMyczE0LjMgMzIgMzIgMzJIMTkyVjQzMmMwIDE3LjcgMTQuMyAzMiAzMiAzMnMzMi0xNC4zIDMyLTMyVjI4OEg0MDBjMTcuNyAwIDMyLTE0LjMgMzItMzJzLTE0LjMtMzItMzItMzJIMjU2VjgweiI+PC9wYXRoPjwvc3ZnPg==) 50% 50% no-repeat; 165 | } 166 | 167 | #editor-tabs li.active { 168 | border-bottom: 1px solid #007bff; 169 | } 170 | 171 | #editor-tabs li:focus { 172 | outline: none; 173 | border-bottom: 1px solid #007bff; 174 | } 175 | 176 | #editor-tabs li.active .close-btn { 177 | visibility: initial; 178 | } 179 | 180 | #editor-tabs li .close-btn { 181 | visibility: hidden; 182 | width: 20px; 183 | height: 20px; 184 | margin: 0 5px; 185 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNOS40MjggOEwxMiAxMC41NzMgMTAuNTcyIDEyIDggOS40MjggNS40MjggMTIgNCAxMC41NzMgNi41NzIgOCA0IDUuNDI4IDUuNDI3IDQgOCA2LjU3MiAxMC41NzMgNCAxMiA1LjQyOCA5LjQyOCA4eiIgZmlsbD0iI0U4RThFOCIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiLz48L3N2Zz4=) 50% no-repeat; 186 | } 187 | 188 | #editor-tabs li:hover .close-btn { 189 | visibility: initial; 190 | } 191 | 192 | #editor-tabs li .close-btn:disabled { 193 | cursor: not-allowed; 194 | opacity: 0.5; 195 | } 196 | 197 | #side-nav { 198 | width: 50px; 199 | background: #111; 200 | height: 100%; 201 | flex-direction: column; 202 | border-right: 1px solid #333; 203 | } 204 | 205 | #side-nav input[type="radio"] { 206 | display: none; 207 | } 208 | 209 | #side-nav input[type="radio"] + label { 210 | cursor: pointer; 211 | padding: 14px; 212 | display: flex; 213 | align-items: center; 214 | justify-content: center; 215 | border-bottom: 1px solid #333; 216 | color: white; 217 | height: 20px; 218 | } 219 | 220 | #side-nav input[type="radio"]:hover { 221 | background-color: rgba(255, 255, 255, 0.2); 222 | } 223 | 224 | #code { 225 | height: calc(100vh - 106px); 226 | } 227 | 228 | #runner { 229 | position: relative; 230 | display: flex; 231 | flex: 1; 232 | flex-direction: column; 233 | } 234 | 235 | #viewer, #devtools { 236 | display: flex; 237 | flex: 1; 238 | } 239 | 240 | #devtools iframe { 241 | border: none; 242 | overflow: hidden; 243 | width: 100%; 244 | } 245 | 246 | #settings #close-settings { 247 | position: absolute; 248 | top: 20px; 249 | left: 20px; 250 | } 251 | 252 | #settings, #sessions { 253 | display: none; 254 | position: absolute; 255 | top: 20px; 256 | left: 20px; 257 | bottom: 20px; 258 | right: 20px; 259 | background: #111; 260 | box-shadow: 0px 0px 10px #111; 261 | padding: 20px; 262 | max-width: 500px; 263 | border: 1px solid #adadad; 264 | } 265 | 266 | #settings h2, 267 | #sessions h2 { 268 | text-align: center; 269 | font-size: 20px; 270 | margin-bottom: 20px; 271 | } 272 | 273 | #settings input[type="text"], 274 | #settings input[type="url"], 275 | #settings input[type="number"], 276 | #settings textarea { 277 | width: 60%; 278 | background: transparent; 279 | color: white; 280 | padding: 5px; 281 | outline: none; 282 | border: 1px solid #aaa; 283 | resize: vertical; 284 | } 285 | 286 | #settings input[type="text"].error, 287 | #settings input[type="url"].error, 288 | #settings textarea.error { 289 | color: red; 290 | border-color: salmon; 291 | } 292 | 293 | #settings form { 294 | width: 100%; 295 | } 296 | 297 | #settings .form-input { 298 | display: flex; 299 | justify-content: space-between; 300 | align-items: center; 301 | } 302 | 303 | #settings .form-checkbox { 304 | display: flex; 305 | margin-bottom: 15px; 306 | } 307 | 308 | #sessions { 309 | flex-direction: column; 310 | } 311 | 312 | #sessions-viewer { 313 | margin: 20px 30px; 314 | } 315 | 316 | #sessions #close-sessions { 317 | position: absolute; 318 | top: 20px; 319 | left: 20px; 320 | } 321 | 322 | #sessions a { 323 | color: white; 324 | border-bottom: 1px dashed white; 325 | } 326 | 327 | .resizer-horizontal { 328 | width: 5px; 329 | background: #555; 330 | cursor: col-resize; 331 | } 332 | 333 | .resizer-vertical { 334 | height: 5px; 335 | background: #555; 336 | cursor: row-resize; 337 | } 338 | 339 | .fixed-message { 340 | position: absolute; 341 | top: 0; 342 | left: 0; 343 | right: 0; 344 | bottom: 0; 345 | background: rgba; 346 | display: flex; 347 | align-items: center; 348 | justify-content: center; 349 | background: rgba(0, 123, 255, .1); 350 | } 351 | 352 | #editor-tabs { 353 | padding-top: 5px; 354 | margin-left: 1em; 355 | } 356 | 357 | #editor-tabs::-webkit-scrollbar { 358 | width: 8px; 359 | height: 8px; 360 | } 361 | 362 | #editor-tabs::-webkit-scrollbar-thumb{ 363 | background: #626262 !important; 364 | } 365 | 366 | 367 | 368 | #editor-tabs::-webkit-scrollbar-corner { 369 | background: transparent; 370 | } 371 | 372 | /* V2 styles */ 373 | 374 | :root { 375 | --tar-700: #222222; 376 | --charchoal-700: #454545; 377 | } 378 | 379 | * { 380 | font-family: "Fira Code", monospace; 381 | } 382 | 383 | header .logo { 384 | display: flex; 385 | gap: 1em; 386 | align-items: center; 387 | font-size: 14pt; 388 | color: #a3a3a3; 389 | font-family: "Fira Code", monospace !important; 390 | cursor: default !important; 391 | } 392 | 393 | header, 394 | #editor-tabs, 395 | #side-nav { 396 | background-color: var(--tar-700); 397 | color: white; 398 | } 399 | 400 | #settings, 401 | #sessions { 402 | border-radius: 0.375rem; 403 | background-color: rgba(34, 34, 34, 0.8) !important; 404 | backdrop-filter: blur(24px); 405 | border: 1px solid var(--charchoal-700); 406 | } 407 | 408 | #settings .form-input { 409 | flex-direction: column; 410 | justify-content: start; 411 | align-items: start; 412 | gap: 0.5em; 413 | } 414 | 415 | #settings input[type="text"], 416 | #settings input[type="url"], 417 | #settings input[type="number"], 418 | #settings textarea { 419 | letter-spacing: normal; 420 | margin: 0px 0px 24px; 421 | box-sizing: border-box; 422 | color: rgb(250, 250, 250); 423 | font-feature-settings: normal; 424 | font-size: 16px; 425 | font-variation-settings: normal; 426 | font-weight: 600; 427 | line-height: 24px; 428 | padding: 8px 16px; 429 | width: 486px; 430 | border-radius: 4px; 431 | background-color: rgb(57, 57, 57); 432 | outline: rgba(0, 0, 0, 0) solid 2px; 433 | outline-offset: 2px; 434 | color-scheme: dark; 435 | overflow: visible; 436 | border: 1px solid rgb(115, 115, 115); 437 | } 438 | 439 | #settings h2, 440 | #sessions h2 { 441 | box-sizing: border-box; 442 | margin-bottom: 1em; 443 | font-size: 24px; 444 | font-weight: 600; 445 | color: rgb(250, 250, 250); 446 | } 447 | 448 | #settings #close-settings { 449 | left: calc(100% - 65px); 450 | } 451 | 452 | .form-input label { 453 | box-sizing: border-box; 454 | font-weight: 600; 455 | color: rgb(250, 250, 250); 456 | line-height: 24px; 457 | } 458 | 459 | #run-button { 460 | color: rgb(255, 255, 255); 461 | font-size: 14.875px; 462 | text-decoration: none solid rgb(255, 255, 255); 463 | background-color: rgb(245, 0, 122) !important; 464 | outline: rgb(255, 255, 255) none 0px; 465 | box-sizing: border-box; 466 | border-radius: 4.25px; 467 | cursor: pointer; 468 | display: block; 469 | line-height: 14.875px; 470 | padding: 13.821px 29.75px; 471 | box-shadow: inset 0 6px 16px rgba(255, 255, 255, 0.2), 472 | inset 0 1px 2px rgba(255, 255, 255, 0.2), 473 | inset 1px 1px 1px rgba(255, 255, 255, 0.05); 474 | flex: 0 0 auto; 475 | font-weight: 700; 476 | text-align: center; 477 | transition: all 0.13s ease 0s; 478 | font-family: Urbanist, Arial, sans-serif; 479 | } 480 | 481 | div#code { 482 | border-radius: 0.375rem !important; 483 | padding: 12px; 484 | background: rgb(34 34 34); 485 | height: 92.6%; 486 | } 487 | 488 | #editor-tabs li { 489 | background: rgb(34 34 34); 490 | border-radius: 0.35em; 491 | } 492 | 493 | #editor-tabs li.active { 494 | background: #454545; 495 | border-bottom: unset !important; 496 | } 497 | 498 | #editor-tabs li .close-btn { 499 | background: url(data:image/svg+xml;base64,PHN2ZyBzdHJva2U9IndoaXRlIiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIwIiB2aWV3Qm94PSIwIDAgNTEyIDUxMiIgaGVpZ2h0PSIxZW0iIHdpZHRoPSIxZW0iIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTI4OS45NCAyNTYgOTUtOTVBMjQgMjQgMCAwIDAgMzUxIDEyN2wtOTUgOTUtOTUtOTVhMjQgMjQgMCAwIDAtMzQgMzRsOTUgOTUtOTUgOTVhMjQgMjQgMCAxIDAgMzQgMzRsOTUtOTUgOTUgOTVhMjQgMjQgMCAwIDAgMzQtMzR6Ij48L3BhdGg+PC9zdmc+) 500 | 50% no-repeat; 501 | } 502 | 503 | .monaco-editor[role="code"] { 504 | padding-top: 1em; 505 | } 506 | 507 | .monaco-editor, 508 | .monaco-editor-background, 509 | .monaco-editor .inputarea.ime-input, 510 | .monaco-editor .margin { 511 | background: #171717 !important; 512 | } 513 | 514 | .monaco-editor[role="code"], 515 | .monaco-scrollable-element.editor-scrollable.vs-dark { 516 | border-top-right-radius: 1em !important; 517 | border-bottom-right-radius: 1em !important; 518 | } 519 | 520 | .monaco-editor[role="code"], 521 | .monaco-editor .overflow-guard > div.margin[role="presentation"] { 522 | border-top-left-radius: 1em !important; 523 | border-bottom-left-radius: 1em !important; 524 | } 525 | 526 | .fixed-message { 527 | width: 95%; 528 | height: 95%; 529 | border-radius: 1em; 530 | margin-left: 1em; 531 | margin-top: 2em; 532 | } 533 | 534 | .resizer-horizontal { 535 | width: 2px; 536 | background: transparent !important; 537 | } 538 | 539 | .resizer-horizontal:hover { 540 | background: #555 !important; 541 | } 542 | 543 | div#editor { 544 | max-height: 90vh; 545 | } 546 | 547 | body { 548 | background: var(--tar-700); 549 | } 550 | 551 | canvas#screencast { 552 | border-top-left-radius: 1em; 553 | border-top-right-radius: 1em; 554 | 555 | width: 95%; 556 | height: 95%; 557 | margin-left: 1em; 558 | margin-top: 2em; 559 | } 560 | 561 | #devtools iframe { 562 | border-bottom-left-radius: 1em; 563 | border-bottom-right-radius: 1em; 564 | 565 | width: 95%; 566 | height: 95%; 567 | margin-left: 1em; 568 | } 569 | 570 | #side-nav input[type="radio"]:hover + label, 571 | #side-nav input[type="radio"]:checked + label { 572 | background: #454545; 573 | } 574 | 575 | #side-nav input[type="radio"] + label { 576 | border-radius: 0.35em; 577 | margin-left: 0.35em; 578 | } 579 | 580 | div.form-input p.form-descriptor { 581 | box-sizing: border-box; 582 | color: #9b9b9b; 583 | line-height: 1em; 584 | font-size: 10pt; 585 | cursor: default; 586 | } 587 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 |
12 | 27 | 28 |
29 | 32 |
37 | 38 | 39 |
40 |
    41 | 42 | 45 | 46 | 47 | 50 | 51 | 52 | 55 |
56 |
57 |
    58 |
    59 |
    60 |
    61 |
    62 |
    63 |
    64 |
    65 | Click the ► button to run your code. 66 |
    67 |
    68 |
    69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | import { Editor } from './editor'; 3 | import { App } from './app'; 4 | import { Settings } from './settings'; 5 | import { Sessions } from './sessions'; 6 | 7 | const $editor = document.getElementById('code') as HTMLElement; 8 | const $settings = document.getElementById('settings') as HTMLElement; 9 | const $sessions = document.getElementById('sessions') as HTMLElement; 10 | 11 | if (!$editor) { 12 | throw new Error(`Couldn't find element to insert code editor!`); 13 | } 14 | 15 | const editor = new Editor($editor); 16 | const settings = new Settings($settings); 17 | const sessions = new Sessions($sessions); 18 | 19 | new App({ editor, settings, sessions }); 20 | -------------------------------------------------------------------------------- /src/puppeteer.worker.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js'; 2 | import { Page, CDPSession } from 'puppeteer-core'; 3 | 4 | import { 5 | ProtocolCommands, 6 | HostCommands, 7 | Message, 8 | WorkerCommands, 9 | } from './types'; 10 | 11 | type UnwrapPromise = T extends Promise ? U : T; 12 | 13 | const protocolCommands = Object.keys(ProtocolCommands); 14 | 15 | let browser: UnwrapPromise> | undefined; 16 | let page: Page | void; 17 | let client: CDPSession | void; 18 | 19 | const sendParentMessage = (message: Message) => { 20 | self.postMessage(message); 21 | }; 22 | 23 | // Override console so that messages show up in the browser's console 24 | // Since we're in a webworker this won't disable console messages in 25 | // the main app itself. 26 | Object.keys(self.console).forEach((consoleMethod: keyof Console) => { 27 | // @ts-ignore 28 | self.console[consoleMethod] = (...args: SerializableOrJSHandle[]) => page && page.evaluate((consoleMethod: keyof Console, ...args) => { 29 | // @ts-ignore 30 | console[consoleMethod](...args); 31 | }, consoleMethod, ...args); 32 | }); 33 | 34 | const start = async(data: Message['data']) => { 35 | const { browserWSEndpoint, quality = 100 } = data; 36 | 37 | browser = await connect({ browserWSEndpoint }) 38 | .catch((error) => { 39 | console.error(error); 40 | return undefined; 41 | }); 42 | 43 | if (!browser) { 44 | sendParentMessage({ 45 | command: WorkerCommands.error, 46 | data: `⚠️ Couldn't establish a connection "${browserWSEndpoint}". Is your browser running?`, 47 | }); 48 | return self.close(); 49 | } 50 | 51 | browser.once('disconnected', () => { 52 | sendParentMessage({ command: WorkerCommands.browserClose, data: null }); 53 | closeWorker(); 54 | }); 55 | page = await browser.newPage() as unknown as Page; 56 | client = await page.createCDPSession(); 57 | 58 | await client.send('Page.startScreencast', { format: 'jpeg', quality }); 59 | 60 | client.on('Page.screencastFrame', onScreencastFrame); 61 | 62 | sendParentMessage({ 63 | command: WorkerCommands.startComplete, 64 | data: { 65 | targetId: (page as any).target()._targetId, 66 | }, 67 | }); 68 | }; 69 | 70 | const onScreencastFrame = ({ data, sessionId }: { data: string; sessionId: number }) => { 71 | if (client) { 72 | client.send('Page.screencastFrameAck', { sessionId }).catch(() => {}); 73 | sendParentMessage({ command: WorkerCommands.screencastFrame, data }); 74 | } 75 | }; 76 | 77 | const setViewport = (data: { width: number, height: number, deviceScaleFactor: number }) => page && page.setViewport(data); 78 | 79 | const runCode = async ({ code }: Message['data']) => { 80 | eval(code)({ page }) 81 | .then(async (res: any) => sendParentMessage({ 82 | command: WorkerCommands.runComplete, 83 | data: { 84 | url: (page as Page).url(), 85 | payload: res, 86 | }, 87 | })) 88 | .catch((e: Error) => { 89 | page && page.evaluate((err) => console.error(err), e.toString()); 90 | }); 91 | }; 92 | 93 | const closeWorker = async() => { 94 | if (browser) browser.disconnect(); 95 | return self.close(); 96 | } 97 | 98 | // Register Commands 99 | self.addEventListener('message', async (message) => { 100 | const { command, data } = message.data as Message; 101 | 102 | if (command === HostCommands.start) { 103 | return start(data); 104 | } 105 | 106 | if (command === HostCommands.run) { 107 | return runCode(data); 108 | } 109 | 110 | if (command === HostCommands.setViewport) { 111 | return setViewport(data); 112 | } 113 | 114 | if (command === HostCommands.close) { 115 | return closeWorker(); 116 | } 117 | 118 | if (protocolCommands.includes(command)) { 119 | if (!client) return; 120 | const protocolCommand = command as ProtocolCommands; 121 | return client.send(protocolCommand, data); 122 | } 123 | 124 | console.debug(`Unknown worker command:`, message); 125 | }, false); 126 | 127 | -------------------------------------------------------------------------------- /src/runner.ts: -------------------------------------------------------------------------------- 1 | import FileType from 'file-type/browser'; 2 | 3 | import { debounce, once } from './util'; 4 | import { 5 | getConnectURL, 6 | getQuality, 7 | getDevtoolsAppURL, 8 | } from './api'; 9 | 10 | import { 11 | ProtocolCommands, 12 | HostCommands, 13 | Message, 14 | WorkerCommands, 15 | } from './types'; 16 | 17 | const runnerHTML = ` 18 |
    19 | 20 |
    21 |
    22 |
    23 | 24 |
    `; 25 | 26 | const errorHTML = (error: string) => `
    ${error.toString()}
    `; 27 | 28 | interface RunnerParams { 29 | code: string; 30 | $mount: HTMLElement; 31 | onClose: (...args: any[]) => void; 32 | } 33 | 34 | export class Runner { 35 | private puppeteerWorker: Worker; 36 | private readonly code: RunnerParams['code']; 37 | private readonly onClose: RunnerParams['onClose']; 38 | private $mount: RunnerParams['$mount']; 39 | private $verticalResizer: HTMLDivElement; 40 | private $iframe: HTMLIFrameElement; 41 | private $canvas: HTMLCanvasElement; 42 | private $viewer: HTMLElement; 43 | private ctx: CanvasRenderingContext2D; 44 | private img = new Image(); 45 | private started = false; 46 | 47 | static getModifiersForEvent(event: any) { 48 | // tslint:disable-next-line: no-bitwise 49 | return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0); 50 | } 51 | 52 | static async makeDownload(response?: string | Uint8Array): Promise<{ 53 | type: string, 54 | payload: any, 55 | } | null> { 56 | if (!response) { 57 | return null; 58 | } 59 | 60 | if (response instanceof Uint8Array) { 61 | const type = (await FileType.fromBuffer(response) || { mime: undefined }).mime; 62 | if (!type) { 63 | return null; 64 | } 65 | return { type, payload: response}; 66 | } 67 | 68 | if (typeof response === 'string') { 69 | return { 70 | type: response.startsWith('<') ? 'text/html' : 'text/plain', 71 | payload: response, 72 | } 73 | } 74 | 75 | if (typeof response === 'object') { 76 | return { 77 | type: 'application/json', 78 | payload: JSON.stringify(response, null, ' '), 79 | }; 80 | } 81 | 82 | return { 83 | type: 'text/plain', 84 | payload: response, 85 | }; 86 | } 87 | 88 | constructor ({ 89 | code, 90 | $mount, 91 | onClose, 92 | }: { 93 | code: string; 94 | $mount: HTMLElement; 95 | onClose: () => void; 96 | }) { 97 | this.$mount = $mount; 98 | this.code = code; 99 | this.onClose = onClose; 100 | 101 | this.setupPuppeteerWorker(); 102 | } 103 | 104 | onVerticalResize = (evt: MouseEvent) => { 105 | evt.preventDefault(); 106 | 107 | this.$mount.style.pointerEvents = 'none'; 108 | this.$viewer.style.flex = 'initial'; 109 | 110 | let onMouseMove: any = (moveEvent: MouseEvent) => { 111 | if (moveEvent.buttons === 0) { 112 | return; 113 | } 114 | 115 | this.$viewer.style.height = `${moveEvent.clientY - 71}px`; 116 | this.$canvas.height = moveEvent.clientY - 71; 117 | }; 118 | 119 | let onMouseUp: any = () => { 120 | this.$mount.style.pointerEvents = 'initial'; 121 | document.removeEventListener('mousemove', onMouseMove); 122 | document.removeEventListener('mouseup', onMouseUp); 123 | onMouseMove = null; 124 | onMouseUp = null; 125 | this.resizePage(); 126 | }; 127 | 128 | document.addEventListener('mousemove', onMouseMove); 129 | document.addEventListener('mouseup', onMouseUp); 130 | }; 131 | 132 | emitMouse = (evt: any) => { 133 | const buttons: any = { 0: 'none', 1: 'left', 2: 'middle', 3: 'right' }; 134 | const event: any = evt.type === 'mousewheel' ? (window.event || evt) : evt; 135 | const types: any = { 136 | mousedown: 'mousePressed', 137 | mouseup: 'mouseReleased', 138 | mousewheel: 'mouseWheel', 139 | touchstart: 'mousePressed', 140 | touchend: 'mouseReleased', 141 | touchmove: 'mouseWheel', 142 | mousemove: 'mouseMoved', 143 | }; 144 | 145 | if (!(event.type in types)) { 146 | return; 147 | } 148 | 149 | if ( 150 | event.type !== 'mousewheel' && 151 | buttons[event.which] === 'none' && 152 | event.type !== 'mousemove' 153 | ) { 154 | return; 155 | } 156 | 157 | const type = types[event.type] as string; 158 | const isScroll = type.indexOf('wheel') !== -1; 159 | const x = isScroll ? event.clientX : event.offsetX; 160 | const y = isScroll ? event.clientY : event.offsetY; 161 | 162 | const data = { 163 | type: types[event.type], 164 | x, 165 | y, 166 | modifiers: Runner.getModifiersForEvent(event), 167 | button: event.type === 'mousewheel' ? 'none' : buttons[event.which], 168 | clickCount: 1 169 | }; 170 | 171 | if (event.type === 'mousewheel') { 172 | // @ts-ignore 173 | data.deltaX = event.wheelDeltaX || 0; 174 | // @ts-ignore 175 | data.deltaY = event.wheelDeltaY || event.wheelDelta; 176 | } 177 | 178 | this.puppeteerWorker.postMessage({ 179 | command: ProtocolCommands['Input.emulateTouchFromMouseEvent'], 180 | data, 181 | }); 182 | }; 183 | 184 | emitKeyEvent = (event: KeyboardEvent) => { 185 | let type; 186 | 187 | // Prevent backspace from going back in history 188 | if (event.keyCode === 8) { 189 | event.preventDefault(); 190 | } 191 | 192 | switch (event.type) { 193 | case 'keydown': 194 | type = 'keyDown'; 195 | break; 196 | case 'keyup': 197 | type = 'keyUp'; 198 | break; 199 | case 'keypress': 200 | type = 'char'; 201 | break; 202 | default: 203 | return; 204 | } 205 | 206 | const text = type === 'char' ? String.fromCharCode(event.charCode) : undefined; 207 | const data = { 208 | type, 209 | text, 210 | unmodifiedText: text ? text.toLowerCase() : undefined, 211 | keyIdentifier: (event as any).keyIdentifier, 212 | code: event.code, 213 | key: event.key, 214 | windowsVirtualKeyCode: event.keyCode, 215 | nativeVirtualKeyCode: event.keyCode, 216 | autoRepeat: false, 217 | isKeypad: false, 218 | isSystemKey: false 219 | }; 220 | 221 | this.puppeteerWorker.postMessage({ 222 | command: ProtocolCommands['Input.dispatchKeyEvent'], 223 | data, 224 | }); 225 | }; 226 | 227 | onScreencastFrame = (data: string) => { 228 | if (!this.ctx) { 229 | return; 230 | } 231 | this.img.onload = () => this.ctx.drawImage(this.img, 0, 0, this.$canvas.width, this.$canvas.height); 232 | this.img.src = 'data:image/png;base64,' + data; 233 | }; 234 | 235 | bindKeyEvents = () => { 236 | document.body.addEventListener('keydown', this.emitKeyEvent, true); 237 | document.body.addEventListener('keyup', this.emitKeyEvent, true); 238 | document.body.addEventListener('keypress', this.emitKeyEvent, true); 239 | }; 240 | 241 | unbindKeyEvents = () => { 242 | document.body.removeEventListener('keydown', this.emitKeyEvent, true); 243 | document.body.removeEventListener('keyup', this.emitKeyEvent, true); 244 | document.body.removeEventListener('keypress', this.emitKeyEvent, true); 245 | }; 246 | 247 | addListeners = () => { 248 | this.$canvas.addEventListener('mousedown', this.emitMouse, false); 249 | this.$canvas.addEventListener('mouseup', this.emitMouse, false); 250 | this.$canvas.addEventListener('mousewheel', this.emitMouse, false); 251 | this.$canvas.addEventListener('mousemove', this.emitMouse, false); 252 | 253 | this.$canvas.addEventListener('mouseenter', this.bindKeyEvents, false); 254 | this.$canvas.addEventListener('mouseleave', this.unbindKeyEvents, false); 255 | 256 | this.$verticalResizer.addEventListener('mousedown', this.onVerticalResize); 257 | 258 | window.addEventListener('resize', this.resizePage); 259 | }; 260 | 261 | removeEventListeners = () => { 262 | if (!this.started) return; 263 | this.$canvas.removeEventListener('mousedown', this.emitMouse, false); 264 | this.$canvas.removeEventListener('mouseup', this.emitMouse, false); 265 | this.$canvas.removeEventListener('mousewheel', this.emitMouse, false); 266 | this.$canvas.removeEventListener('mousemove', this.emitMouse, false); 267 | 268 | this.$canvas.removeEventListener('mouseenter', this.bindKeyEvents, false); 269 | this.$canvas.removeEventListener('mouseleave', this.unbindKeyEvents, false); 270 | 271 | this.$verticalResizer.removeEventListener('mousedown', this.onVerticalResize); 272 | 273 | window.removeEventListener('resize', this.resizePage); 274 | }; 275 | 276 | resizePage = debounce(() => { 277 | const { width, height } = this.$viewer.getBoundingClientRect(); 278 | 279 | this.$canvas.width = width - 5; 280 | this.$canvas.height = height; 281 | 282 | this.sendWorkerMessage({ 283 | command: 'setViewport', 284 | data: { 285 | width: Math.floor(width), 286 | height: Math.floor(height), 287 | deviceScaleFactor: 1, 288 | } 289 | }); 290 | }, 500); 291 | 292 | close = once((...args: any[]) => { 293 | this.onClose(...args); 294 | this.sendWorkerMessage({ command: HostCommands.close, data: null }); 295 | this.removeEventListeners(); 296 | this.unbindKeyEvents(); 297 | }); 298 | 299 | showError = (err: string) => { 300 | this.$mount.innerHTML = `${errorHTML(err)}`; 301 | }; 302 | 303 | onRunComplete = async ({ url, payload }: { url: string, payload: any }) => { 304 | const download = await Runner.makeDownload(payload); 305 | 306 | if (!download) { 307 | return null; 308 | } 309 | 310 | const title = new URL(url).hostname.replace(/\W/g, '-'); 311 | const blob = new Blob([download.payload], { type: download.type }); 312 | const link = document.createElement('a'); 313 | link.href = window.URL.createObjectURL(blob); 314 | 315 | const fileName = title; 316 | link.download = fileName; 317 | return link.click(); 318 | }; 319 | 320 | sendWorkerMessage = (message: Message) => { 321 | this.puppeteerWorker.postMessage(message); 322 | }; 323 | 324 | onIframeLoad = () => { 325 | this.$iframe.removeEventListener('load', this.onIframeLoad); 326 | this.sendWorkerMessage({ 327 | command: HostCommands.run, 328 | data: { 329 | code: this.code, 330 | }, 331 | }); 332 | }; 333 | 334 | onWorkerSetupComplete = (payload: Message['data']) => { 335 | const { targetId } = payload; 336 | const iframeURL = getDevtoolsAppURL(targetId); 337 | 338 | this.started = true; 339 | this.$mount.innerHTML = runnerHTML; 340 | this.$iframe = document.querySelector('#devtools-mount') as HTMLIFrameElement; 341 | this.$viewer = document.querySelector('#viewer') as HTMLDivElement; 342 | this.$canvas = document.querySelector('#screencast') as HTMLCanvasElement; 343 | this.$verticalResizer = document.querySelector('#resize-vertical') as HTMLDivElement; 344 | this.ctx = this.$canvas.getContext('2d') as CanvasRenderingContext2D; 345 | this.$iframe.addEventListener('load', this.onIframeLoad); 346 | this.$iframe.src = iframeURL; 347 | 348 | this.addListeners(); 349 | this.resizePage(); 350 | }; 351 | 352 | setupPuppeteerWorker = () => { 353 | this.puppeteerWorker = new Worker('puppeteer.worker.bundle.js'); 354 | this.puppeteerWorker.addEventListener('message', (evt) => { 355 | const { command, data } = evt.data as Message; 356 | 357 | if (command === WorkerCommands.startComplete) { 358 | return this.onWorkerSetupComplete(data); 359 | } 360 | 361 | if (command === WorkerCommands.screencastFrame) { 362 | return this.onScreencastFrame(data) 363 | } 364 | 365 | if (command === WorkerCommands.runComplete) { 366 | return this.onRunComplete(data); 367 | } 368 | 369 | if (command === WorkerCommands.error) { 370 | return this.showError(data); 371 | } 372 | 373 | if (command === WorkerCommands.browserClose) { 374 | return this.showError(`Session complete! Browser has closed.`); 375 | } 376 | }); 377 | 378 | this.puppeteerWorker.addEventListener('error', ({ message }) => { 379 | this.puppeteerWorker.terminate(); 380 | return this.showError(`Error communicating with puppeteer-worker ${message}`); 381 | }); 382 | 383 | this.sendWorkerMessage({ 384 | command: 'start', 385 | data: { 386 | browserWSEndpoint: getConnectURL(), 387 | quality: getQuality(), 388 | }, 389 | }); 390 | }; 391 | } 392 | -------------------------------------------------------------------------------- /src/sessions.ts: -------------------------------------------------------------------------------- 1 | import { fetchSessions, getDevtoolsInspectorURL } from './api'; 2 | 3 | const sessionsHTML = ` 4 | 7 |

    Current Sessions

    8 |
      9 |
    `; 10 | 11 | export class Sessions { 12 | private onCloseHandler = () => {}; 13 | private $mount: HTMLElement; 14 | private $list: HTMLElement; 15 | private interval: ReturnType 16 | 17 | constructor($mount: HTMLElement) { 18 | this.$mount = $mount; 19 | this.$mount.innerHTML = sessionsHTML; 20 | this.$list = document.querySelector('#sessions-viewer') as HTMLElement; 21 | 22 | this.addListeners(); 23 | } 24 | 25 | addListeners = () => { 26 | document.querySelector('#close-sessions')?.addEventListener('click', this.onCloseClick); 27 | }; 28 | 29 | removeListeners = () => { 30 | document.querySelector('#close-sessions')?.removeEventListener('click', this.onCloseClick); 31 | window.clearInterval(this.interval); 32 | }; 33 | 34 | onCloseClick = () => { 35 | this.onCloseHandler(); 36 | }; 37 | 38 | toggleVisibility = (visible?: boolean) => { 39 | const display = (() => { 40 | if (typeof visible === 'boolean') { 41 | return visible ? 'flex' : 'none'; 42 | } 43 | return this.$mount.style.display === 'flex' ? 'none' : 'flex'; 44 | })(); 45 | 46 | this.$mount.style.display = display; 47 | 48 | if (display === 'flex') { 49 | this.getSessions(); 50 | this.interval = window.setInterval(this.getSessions, 2500); 51 | } else { 52 | window.clearInterval(this.interval); 53 | } 54 | } 55 | 56 | getSessions = async () => { 57 | const links = (await fetchSessions()) 58 | .filter((s: any) => s.url !== 'about:blank') 59 | .map((s: any) =>`
  1. ${s.title}
  2. `) 60 | .join('\n'); 61 | 62 | this.$list.innerHTML = links; 63 | }; 64 | 65 | onClose = (handler: () => void) => this.onCloseHandler = handler; 66 | } 67 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getWebSocketURL, 3 | getHeadless, 4 | getStealth, 5 | getAds, 6 | getIgnoreHTTPS, 7 | setBrowserURL, 8 | setHeadless, 9 | setStealth, 10 | setAds, 11 | setIgnoreHTTPS, 12 | getQuality, 13 | setQuality, 14 | } from './api'; 15 | 16 | const settingsHTML = ` 17 | 20 |
    21 |

    Debugger Settings

    22 |
    23 | 24 | 25 |
    26 | 27 |
    28 | 29 |

    The quality of the video stream (100 is best, 0 is worst but less data.)

    30 | 31 |
    32 | 33 |
    34 | 35 | 36 |
    37 | 38 |
    39 | 40 | 41 |
    42 | 43 |
    44 | 45 | 46 |
    47 | 48 |
    49 | 50 | 51 |
    52 | 53 |
    54 | 55 | 56 |
    57 |
    `; 58 | 59 | export class Settings { 60 | private onCloseHandler = () => {}; 61 | 62 | private $mount: HTMLElement; 63 | private $browserInput: HTMLInputElement; 64 | private $headlessInput: HTMLInputElement; 65 | private $stealthInput: HTMLInputElement; 66 | private $blockAdsInput: HTMLInputElement; 67 | private $ignoreHTTPSInput: HTMLInputElement; 68 | private $screencastQuality: HTMLInputElement; 69 | 70 | constructor($mount: HTMLElement) { 71 | this.$mount = $mount; 72 | this.$mount.innerHTML = settingsHTML; 73 | 74 | this.$browserInput = document.querySelector('#websocket-endpoint') as HTMLInputElement; 75 | this.$headlessInput = document.querySelector('#headless') as HTMLInputElement; 76 | this.$ignoreHTTPSInput = document.querySelector('#ignore-https') as HTMLInputElement; 77 | this.$stealthInput = document.querySelector('#stealth') as HTMLInputElement; 78 | this.$blockAdsInput = document.querySelector('#block-ads') as HTMLInputElement; 79 | this.$screencastQuality = document.querySelector('#screencast-quality') as HTMLInputElement; 80 | 81 | this.hydrate(); 82 | this.addListeners(); 83 | } 84 | 85 | hydrate = () => { 86 | this.$browserInput.value = getWebSocketURL().href; 87 | this.$headlessInput.checked = getHeadless() 88 | this.$ignoreHTTPSInput.checked = getIgnoreHTTPS(); 89 | this.$stealthInput.checked = getStealth(); 90 | this.$blockAdsInput.checked = getAds(); 91 | this.$screencastQuality.value = getQuality().toString(); 92 | }; 93 | 94 | addListeners = () => { 95 | document.querySelector('#close-settings')?.addEventListener('click', this.onCloseClick); 96 | this.$browserInput.addEventListener('change', this.onBrowserInputChange); 97 | this.$blockAdsInput.addEventListener('change', this.onBlockAdsInputChange); 98 | this.$headlessInput.addEventListener('change', this.onHeadlessInputChange); 99 | this.$ignoreHTTPSInput.addEventListener('change', this.onIgnoreHTTPSInputChange); 100 | this.$stealthInput.addEventListener('change', this.onStealthInputChange); 101 | this.$screencastQuality.addEventListener('change', this.onScreencastQualityChange); 102 | }; 103 | 104 | removeListeners = () => { 105 | document.querySelector('#close-settings')?.removeEventListener('click', this.onCloseClick); 106 | this.$browserInput.removeEventListener('change', this.onBrowserInputChange); 107 | this.$blockAdsInput.removeEventListener('change', this.onBlockAdsInputChange); 108 | this.$headlessInput.removeEventListener('change', this.onHeadlessInputChange); 109 | this.$ignoreHTTPSInput.removeEventListener('change', this.onIgnoreHTTPSInputChange); 110 | this.$stealthInput.removeEventListener('change', this.onStealthInputChange); 111 | this.$screencastQuality.removeEventListener('change', this.onScreencastQualityChange); 112 | }; 113 | 114 | onCloseClick = () => { 115 | this.onCloseHandler(); 116 | }; 117 | 118 | onScreencastQualityChange = (evt: Event) => { 119 | const val = (evt.target as HTMLInputElement).value; 120 | setQuality(+val); 121 | }; 122 | 123 | onBlockAdsInputChange = (evt: Event) => { 124 | const checked = (evt.target as HTMLInputElement).checked; 125 | setAds(checked); 126 | }; 127 | 128 | onHeadlessInputChange = (evt: Event) => { 129 | const checked = (evt.target as HTMLInputElement).checked; 130 | setHeadless(checked); 131 | }; 132 | 133 | onIgnoreHTTPSInputChange = (evt: Event) => { 134 | const checked = (evt.target as HTMLInputElement).checked; 135 | setIgnoreHTTPS(checked); 136 | }; 137 | 138 | onStealthInputChange = (evt: Event) => { 139 | const checked = (evt.target as HTMLInputElement).checked; 140 | setStealth(checked); 141 | }; 142 | 143 | onClose = (handler: () => void) => this.onCloseHandler = handler; 144 | 145 | toggleVisibility = (visible?: boolean) => { 146 | const display = (() => { 147 | if (typeof visible === 'boolean') { 148 | return visible ? 'flex' : 'none'; 149 | } 150 | return this.$mount.style.display === 'flex' ? 'none' : 'flex'; 151 | })(); 152 | 153 | this.$mount.style.display = display; 154 | }; 155 | 156 | onBrowserInputChange = (evt: Event) => { 157 | const $input = evt.target as HTMLInputElement 158 | const text = $input.value; 159 | const { valid, message } = setBrowserURL(text); 160 | 161 | if (!valid) { 162 | this.$browserInput.title = message; 163 | this.$browserInput.classList.add('error'); 164 | return; 165 | } 166 | 167 | this.$browserInput.title = 'Sets a WebSocket URL to run your sessions on.'; 168 | this.$browserInput.classList.remove('error'); 169 | }; 170 | } 171 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | const _LOCAL_STORAGE_KEY = `browserless-debugger:` + window.location.origin + window.location.pathname; 2 | const hasLocalStorage = (() => { 3 | let can = false; 4 | 5 | try { 6 | window.localStorage.getItem(_LOCAL_STORAGE_KEY); 7 | can = true; 8 | } catch(e) { 9 | console.error(`Error writing to local-storage: ${e}`); 10 | can = false; 11 | } 12 | 13 | return can; 14 | })(); 15 | 16 | const state: Record = (() => { 17 | const prior = hasLocalStorage ? window.localStorage.getItem(_LOCAL_STORAGE_KEY) : '{}'; 18 | let priorState: Record; 19 | 20 | try { 21 | priorState = JSON.parse(prior || '{}'); 22 | } catch { 23 | priorState = {}; 24 | } 25 | 26 | return priorState; 27 | })(); 28 | 29 | const writeState = (value: Record) => { 30 | if (hasLocalStorage) { 31 | window.localStorage.setItem(_LOCAL_STORAGE_KEY, JSON.stringify(value)); 32 | } 33 | } 34 | 35 | export const get = (key: string) => state.hasOwnProperty(key) ? state[key] : null; 36 | 37 | export const set = (key: string, value:T): T => { 38 | state[key] = value; 39 | writeState(state); 40 | 41 | return value; 42 | }; 43 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export enum ProtocolCommands { 2 | 'Input.dispatchKeyEvent' = 'Input.dispatchKeyEvent', 3 | 'Input.emulateTouchFromMouseEvent' = 'Input.emulateTouchFromMouseEvent', 4 | } 5 | 6 | export enum HostCommands { 7 | 'start' = 'start', 8 | 'run' = 'run', 9 | 'close' = 'close', 10 | 'setViewport' = 'setViewport', 11 | } 12 | 13 | export enum WorkerCommands { 14 | 'startComplete' = 'startComplete', 15 | 'runComplete' = 'runComplete', 16 | 'screencastFrame' = 'screencastFrame', 17 | 'browserClose' = 'browserClose', 18 | 'error' = 'error', 19 | } 20 | 21 | export interface Message { 22 | command: string, 23 | data: any, 24 | } 25 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export type SideEffectFn = (...args: any[]) => void; 2 | 3 | export type Options = { 4 | isImmediate?: boolean; 5 | maxWait?: number; 6 | }; 7 | 8 | export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 9 | 10 | export const splitByWhitespace = (str: string) => str.split(/\s+/).filter(Boolean); 11 | 12 | export interface DebouncedFunction { 13 | (this: ThisParameterType, ...args: Parameters): void; 14 | cancel: () => void; 15 | } 16 | 17 | export const once = ( 18 | fn: (this: T, ...arg: A) => R, 19 | ): ((this: T, ...arg: A) => R | undefined) => { 20 | let done = false; 21 | return function (this: T, ...args: A) { 22 | return done ? void 0 : ((done = true), fn.apply(this, args)); 23 | }; 24 | }; 25 | 26 | export function debounce( 27 | func: F, 28 | waitMilliseconds = 50, 29 | options: Options = {}, 30 | ): DebouncedFunction { 31 | let timeoutId: ReturnType | undefined; 32 | const isImmediate = options.isImmediate ?? false; 33 | const maxWait = options.maxWait; 34 | let lastInvokeTime = Date.now(); 35 | 36 | function nextInvokeTimeout() { 37 | if (maxWait !== undefined) { 38 | const timeSinceLastInvocation = Date.now() - lastInvokeTime; 39 | 40 | if (timeSinceLastInvocation + waitMilliseconds >= maxWait) { 41 | return maxWait - timeSinceLastInvocation; 42 | } 43 | } 44 | 45 | return waitMilliseconds; 46 | } 47 | 48 | const debouncedFunction = function ( 49 | this: ThisParameterType, 50 | ...args: Parameters 51 | ) { 52 | const context = this; 53 | 54 | const invokeFunction = function invokeFunction() { 55 | timeoutId = undefined; 56 | lastInvokeTime = Date.now(); 57 | if (!isImmediate) { 58 | func.apply(context, args); 59 | } 60 | }; 61 | 62 | const shouldCallNow = isImmediate && timeoutId === undefined; 63 | 64 | if (timeoutId !== undefined) { 65 | clearTimeout(timeoutId); 66 | } 67 | 68 | timeoutId = setTimeout(invokeFunction, nextInvokeTimeout()); 69 | 70 | if (shouldCallNow) { 71 | func.apply(context, args); 72 | } 73 | }; 74 | 75 | debouncedFunction.cancel = function cancel() { 76 | if (timeoutId !== undefined) { 77 | clearTimeout(timeoutId); 78 | } 79 | }; 80 | 81 | return debouncedFunction; 82 | } 83 | -------------------------------------------------------------------------------- /tracker.js: -------------------------------------------------------------------------------- 1 | const loadPostHog = () => { 2 | !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n { 7 | var script=document.createElement("script");script.src="https://www.googletagmanager.com/gtag/js?id=G-25NMGWY45L",script.onload=function(){function a(){dataLayer.push(arguments)}window.dataLayer=window.dataLayer||[],a("js",new Date),a("config","G-25NMGWY45L")},document.head.appendChild(script); 8 | } 9 | 10 | const loadGtagManager = () => { 11 | !function(e,t,a,n,g){e[n]=e[n]||[],e[n].push({"gtm.start":(new Date).getTime(),event:"gtm.js"});var m=t.getElementsByTagName(a)[0],r=t.createElement(a);r.async=!0,r.src="https://www.googletagmanager.com/gtm.js?id=GTM-M78W82C8",m.parentNode.insertBefore(r,m)}(window,document,"script","dataLayer"); 12 | } 13 | 14 | const loadAmplitude = async () => { 15 | !function(){"use strict";!function(e,t){var r=e.amplitude||{_q:[],_iq:{}};if(r.invoked)e.console&&console.error&&console.error("Amplitude snippet has been loaded.");else{var n=function(e,t){e.prototype[t]=function(){return this._q.push({name:t,args:Array.prototype.slice.call(arguments,0)}),this}},s=function(e,t,r){return function(n){e._q.push({name:t,args:Array.prototype.slice.call(r,0),resolve:n})}},o=function(e,t,r){e._q.push({name:t,args:Array.prototype.slice.call(r,0)})},i=function(e,t,r){e[t]=function(){if(r)return{promise:new Promise(s(e,t,Array.prototype.slice.call(arguments)))};o(e,t,Array.prototype.slice.call(arguments))}},a=function(e){for(var t=0;t