Browser Live View Example avatar
Browser Live View Example

Pricing

Pay per usage

Go to Store
Browser Live View Example

Browser Live View Example

Developed by

Matyáš Cimbulka

Matyáš Cimbulka

Maintained by Community

0.0 (0)

Pricing

Pay per usage

0

Total users

2

Monthly users

2

Runs succeeded

>99%

Last modified

4 days ago

.dockerignore

# configurations
.idea
.vscode
# crawlee and apify storage folders
apify_storage
crawlee_storage
storage
# installed files
node_modules
# git folder
.git

.editorconfig

root = true
[*]
indent_style = space
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf

.gitignore

# This file tells Git which files shouldn't be added to source control
.DS_Store
.idea
.vscode
.zed
dist
node_modules
apify_storage
storage
# Added by Apify CLI
.venv

eslint.config.mjs

1import apifyTypescriptConfig from '@apify/eslint-config/ts.js';
2
3// eslint-disable-next-line import/no-default-export
4export default [
5 { ignores: ['**/dist'] }, // Ignores need to happen first
6 ...apifyTypescriptConfig,
7 {
8 languageOptions: {
9 sourceType: 'module',
10
11 parserOptions: {
12 project: 'tsconfig.json',
13 },
14 rules: {
15 'no-use-before-define': 'off',
16 },
17 },
18 },
19];

package.json

{
"name": "chrome-dev-tools",
"version": "0.0.1",
"type": "module",
"description": "This is an example of an Apify actor.",
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@fastify/cors": "^11.0.1",
"@fastify/static": "^8.1.1",
"@fastify/websocket": "^11.0.2",
"apify": "^3.2.6",
"cors": "^2.8.5",
"crawlee": "^3.11.5",
"express": "^5.1.0",
"express-ws": "^5.0.2",
"fastify": "^5.3.3",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.5",
"http-terminator": "^3.2.0",
"httpxy": "^0.1.7",
"playwright": "*",
"puppeteer": "*",
"simple-get": "^4.0.1"
},
"devDependencies": {
"@apify/eslint-config": "^1.0.0",
"@apify/tsconfig": "^0.1.0",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.1",
"@types/express-ws": "^3.0.5",
"@types/http-proxy": "^1.17.16",
"@types/simple-get": "^4.0.3",
"@types/ws": "^8.18.1",
"eslint": "^9.26.0",
"tsx": "^4.6.2",
"typescript": "^5.3.3",
"typescript-eslint": "^8.32.1"
},
"scripts": {
"start": "npm run start:dev",
"start:prod": "node dist/main.js",
"start:dev": "tsx src/main.ts",
"build": "tsc",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "echo \"Error: oops, the actor has no tests yet, sad!\" && exit 1",
"postinstall": "npx crawlee install-playwright-browsers"
},
"author": "It's not you it's me",
"license": "ISC"
}

tsconfig.json

{
"extends": "@apify/tsconfig",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "dist",
"noUnusedLocals": false,
"skipLibCheck": true,
"lib": ["DOM"],
},
"include": [
"./src/**/*"
]
}

.actor/Dockerfile

# Specify the base Docker image. You can read more about
# the available images at https://crawlee.dev/docs/guides/docker-images
# You can also use any other image from Docker Hub.
FROM apify/actor-node-playwright-chrome:20 AS builder
# Check preinstalled packages
RUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json
# to speed up the build using Docker layer cache.
COPY --chown=myuser package*.json ./
# Install all dependencies. Don't audit to speed up the installation.
RUN npm install --include=dev --audit=false --force
# Next, copy the source files using the user set
# in the base image.
COPY --chown=myuser . ./
# Copy the public directory for build-time needs
COPY --chown=myuser public ./public
# Install all dependencies and build the project.
# Don't audit to speed up the installation.
RUN npm run build
# Create final image
FROM apify/actor-node-playwright-chrome:20
# Check preinstalled packages
RUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json
# to speed up the build using Docker layer cache.
COPY --chown=myuser package*.json ./
# Install NPM packages, skip optional and development dependencies to
# keep the image small. Avoid logging too much and print the dependency
# tree for debugging
RUN npm --quiet set progress=false \
&& npm install --omit=dev --omit=optional --force \
&& echo "Installed NPM packages:" \
&& (npm list --omit=dev --all || true) \
&& echo "Node.js version:" \
&& node --version \
&& echo "NPM version:" \
&& npm --version \
&& rm -r ~/.npm
# Copy built JS files from builder image
COPY --from=builder --chown=myuser /home/myuser/dist ./dist
# Copy the public directory from the builder image
COPY --from=builder --chown=myuser /home/myuser/public ./public
# Next, copy the remaining files and directories with the source code.
# Since we do this after NPM install, quick build will be really fast
# for most source file changes.
COPY --chown=myuser . ./
# Run the image. If you know you won't need headful browsers,
# you can remove the XVFB start script for a micro perf gain.
CMD ./start_xvfb_and_run_cmd.sh && npm run start:prod --silent

.actor/actor.json

{
"actorSpecification": 1,
"name": "browser-live-view-example",
"title": "Browser Live View Example",
"description": "This is an example implementation of browser live view.",
"version": "0.0",
"meta": {
"templateId": "ts-crawlee-playwright-chrome"
},
"input": "./input_schema.json",
"dockerfile": "./Dockerfile"
}

.actor/input_schema.json

{
"title": "PlaywrightCrawler Template",
"type": "object",
"schemaVersion": 1,
"properties": {
"url": {
"title": "URL",
"type": "string",
"description": "URL to navigate to",
"editor": "textfield",
"prefill": "https://apify.com"
},
"proxyConfiguration": {
"title": "Proxy configuration",
"type": "object",
"description": "Proxy settings for the crawler (see Apify proxy configuration object)",
"editor": "proxy",
"prefill": { "useApifyProxy": true }
}
}
}

src/cdp-proxy-server.ts

1import express from 'express';
2import cors from 'cors';
3import expressWs from 'express-ws';
4import { CDPSession } from 'playwright';
5import path from 'node:path';
6
7export const createCdpProxyServer = async (cdpSession: CDPSession) => {
8 const app = express();
9 expressWs(app);
10
11 app.use(cors({ origin: '*', credentials: true }));
12 app.use(express.static(path.join(path.dirname(new URL(import.meta.url).pathname), '../public')));
13
14 app.get('/health', (_req, res) => {
15 res.status(200).send('OK');
16 });
17
18 (app as unknown as expressWs.Application).ws('/cdp', (ws, _req) => {
19 console.info('Client connected');
20
21 const eventHandler = (eventName: string, eventData: any) => {
22 ws.send(JSON.stringify({ method: eventName, params: eventData }));
23 };
24
25 const originalEmitFn = (cdpSession as any).emit;
26 (cdpSession as any).emit = (...args: any[]) => {
27 const [eventName, eventData] = args;
28 eventHandler(eventName, eventData);
29 originalEmitFn.apply(cdpSession, args);
30 };
31
32 interface CDPCommand {
33 id: number;
34 method: string;
35 params?: any;
36 }
37
38 ws.on('message', async (message: Buffer) => {
39 const { id, method, params } = JSON.parse(message.toString('utf8')) as CDPCommand;
40 try {
41 const response = await cdpSession.send(method as any, params);
42 ws.send(JSON.stringify({ id, result: response }));
43 } catch (error) {
44 ws.send(JSON.stringify({ id, error: { message: (error as Error).message ?? '' } }));
45 return;
46 }
47 });
48
49 ws.on('error', (err: Error) => {
50 console.error(err.message);
51 });
52
53 ws.on('close', () => {
54 console.info('Connection closed');
55 });
56 });
57
58 // Return the app and the underlying server
59 return {
60 app,
61 listen: (opts: { port: number }, cb: (err?: Error, address?: string) => void) => {
62 const server = app.listen(opts.port, () => {
63 cb(undefined, `http://localhost:${opts.port}`);
64 });
65 server.on('error', (err) => cb(err));
66 return server;
67 },
68 close: (server: any) => new Promise<void>((resolve, reject) => {
69 server.close((err: any) => {
70 if (err) reject(err);
71 else resolve();
72 });
73 })
74 };
75};

src/main.ts

1import { Actor } from 'apify';
2import { PlaywrightCrawler, ProxyConfigurationOptions } from 'crawlee';
3
4import { createCdpProxyServer } from './cdp-proxy-server.js';
5import { router } from './routes.js';
6
7interface Input {
8 url: string;
9 proxyConfiguration: ProxyConfigurationOptions;
10}
11
12await Actor.init();
13
14const { containerUrl, containerPort } = Actor.getEnv();
15
16// const CONTAINER_HOST = Actor.isAtHome() ? containerUrl : 'localhost';
17// const CONTAINER_PORT = containerPort;
18// const CHROME_DEBUGGING_PORT = 9222;
19
20const {
21 url = 'https://www.apify.com',
22 proxyConfiguration: proxyConfigurationOptions = { useApifyProxy: true },
23} = await Actor.getInput<Input>() ?? {} as Input;
24
25const proxyConfiguration = await Actor.createProxyConfiguration(proxyConfigurationOptions);
26
27let server: any | undefined;
28let httpServer: any | undefined;
29
30const crawler = new PlaywrightCrawler({
31 proxyConfiguration,
32 requestHandler: router,
33 maxConcurrency: 1,
34 requestHandlerTimeoutSecs: 3600, // 1 hour
35 navigationTimeoutSecs: 3600,
36 headless: true,
37 launchContext: {
38 launchOptions: {
39 args: [
40 // `--remote-debugging-port=${CHROME_DEBUGGING_PORT}`, // Enable remote debugging
41 // '--remote-debugging-address=0.0.0.0',
42 ],
43 },
44 },
45 preNavigationHooks: [
46 async ({ page }) => {
47 const context = page.context();
48 const client = await context.newCDPSession(page);
49
50 // Create the proxy server
51 server = await createCdpProxyServer(client);
52 httpServer = server.listen({ port: containerPort ?? 4321 }, (err: any, address?: string) => {
53 if (err) {
54 console.error('Error starting server:', err);
55 return;
56 }
57
58 console.log(`Listening on ${address}`);
59 });
60 },
61 ],
62});
63
64await crawler.run([{ url }]);
65
66if (server && httpServer) {
67 console.log('Closing server');
68 await server.close(httpServer);
69}
70
71await Actor.exit();

src/routes.ts

1import { createPlaywrightRouter, createPuppeteerRouter, sleep } from 'crawlee';
2
3export const router = createPlaywrightRouter();
4
5router.addDefaultHandler(async ({ log, page }) => {
6 log.info('Waiting for network idle');
7
8 log.info('Sleeping for 20 minutes');
9 await page.waitForTimeout(20 * 60 * 1000);
10 // await sleep(20 * 60 * 1000);
11
12 log.info('Done -> closing page');
13});

public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
<title>Remote browser</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta property="og:title" content="Remote browser">
<meta property="og:description" content="Control your remote browser">
<style>
html, body {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family: "Courier", monospace;
height: 100vh;
width: 100vw;
}
#imageContainer {
position: relative;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#imageContainer img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
</style>
</head>
<body>
<div id="imageContainer">
<p>Waiting for image...</p>
</div>
<script>
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${protocol}://${window.location.host}/cdp`);
const imageContainer = document.getElementById('imageContainer');
let imgElement = null;
let requestIdCounter = 1;
const pendingRequests = new Map();
const eventHandlers = new Map();
socket.addEventListener('open', async () => {
console.log('WebSocket connection opened');
// Function to resize remote browser
const resizeRemote = async () => {
const rect = imageContainer.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
await sendCdpCommand('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: window.devicePixelRatio || 1,
mobile: false,
screenWidth: width,
screenHeight: height,
});
};
// Initial resize
await resizeRemote();
const version = await sendCdpCommand('Browser.getVersion');
console.log('Browser version:', version);
// Start the screencast
await sendCdpCommand('Page.startScreencast', {
format: 'jpeg',
quality: 100
});
// Listen for window resize and update remote browser
window.addEventListener('resize', async () => {
await resizeRemote();
});
});
const sendCdpCommand = (method, params) => {
return new Promise((resolve, reject) => {
const requestId = requestIdCounter++;
try {
socket.send(JSON.stringify({ id: requestId, method, params }));
pendingRequests.set(requestId, { resolve, reject });
} catch (err) {
reject(err);
}
});
};
socket.addEventListener('message', (event) => {
try {
const message = JSON.parse(event.data);
if ('id' in message) {
console.dir(message);
if ('result' in message) {
const callback = pendingRequests.get(message.id);
if (callback) {
callback.resolve(message.result);
pendingRequests.delete(message.id);
}
} else {
const callback = pendingRequests.get(message.id);
if (callback) {
callback.reject(new Error('Error: ', message.error.message));
pendingRequests.delete(message.id);
}
}
} else {
const { method, params } = message;
const callback = eventHandlers.get(method);
if (callback) {
callback(params);
}
}
} catch (err) {
console.error('Error parsing message:', err);
}
});
eventHandlers.set('Page.screencastFrame', async (params) => {
const { sessionId, data } = params;
const imageUrl = 'data:image/jpeg;base64,' + data;
const imageContainer = document.getElementById('imageContainer');
if (!imgElement) {
imgElement = document.createElement('img');
imgElement.id = 'image';
imageContainer.innerHTML = '';
imageContainer.appendChild(imgElement);
addMouseEventListeners(imgElement);
}
imgElement.src = imageUrl;
imgElement.alt = 'Image from screencast';
await sendCdpCommand('Page.screencastFrameAck', { sessionId });
});
const addMouseEventListeners = (element) => {
element.addEventListener('mousedown', async (event) => {
const { button, clientX, clientY } = event;
event.preventDefault();
const rect = imgElement.getBoundingClientRect();
await sendCdpCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: Math.floor(clientX - rect.left),
y: Math.floor(clientY - rect.top),
button: button === 0 ? 'left' : button === 1 ? 'middle' : 'right',
clickCount: 1,
});
});
element.addEventListener('mouseup', async (event) => {
const { button, clientX, clientY } = event;
event.preventDefault();
const rect = imgElement.getBoundingClientRect();
await sendCdpCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: Math.floor(clientX - rect.left),
y: Math.floor(clientY - rect.top),
button: button === 0 ? 'left' : button === 1 ? 'middle' : 'right',
clickCount: 1,
});
});
element.addEventListener('wheel', async (event) => {
const { clientX, clientY, deltaX, deltaY } = event;
event.preventDefault();
const rect = imgElement.getBoundingClientRect();
const x = Math.floor(clientX - rect.left);
const y = Math.floor(clientY - rect.top);
await sendCdpCommand('Input.dispatchMouseEvent', {
type: 'mouseWheel',
x,
y,
deltaX,
deltaY,
modifiers: 0,
button: 'none',
}, { passive: false });
});
element.addEventListener('contextmenu', async (event) => {
event.preventDefault();
const rect = imgElement.getBoundingClientRect();
await sendCdpCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: Math.floor(event.clientX - rect.left),
y: Math.floor(event.clientY - rect.top),
button: 'right',
clickCount: 1,
});
await sendCdpCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: Math.floor(event.clientX - rect.left),
y: Math.floor(event.clientY - rect.top),
button: 'right',
clickCount: 1,
});
});
element.addEventListener('mousemove', async (event) => {
const { clientX, clientY } = event;
const rect = imgElement.getBoundingClientRect();
await sendCdpCommand('Input.dispatchMouseEvent', {
type: 'mouseMoved',
x: Math.floor(clientX - rect.left),
y: Math.floor(clientY - rect.top),
button: 'none',
clickCount: 0,
});
});
};
// Keyboard event handling
window.addEventListener('keydown', async (event) => {
// Ignore modifier keys alone
if (event.key.length === 1 || event.key === 'Enter' || event.key === 'Backspace' || event.key === 'Tab') {
await sendCdpCommand('Input.dispatchKeyEvent', {
type: 'keyDown',
key: event.key,
code: event.code,
keyCode: event.keyCode,
windowsVirtualKeyCode: event.keyCode,
text: event.key.length === 1 ? event.key : undefined,
unmodifiedText: event.key.length === 1 ? event.key : undefined,
modifiers: (event.shiftKey ? 8 : 0) | (event.ctrlKey ? 4 : 0) | (event.altKey ? 2 : 0) | (event.metaKey ? 1 : 0),
});
event.preventDefault();
}
});
window.addEventListener('keyup', async (event) => {
if (event.key.length === 1 || event.key === 'Enter' || event.key === 'Backspace' || event.key === 'Tab') {
await sendCdpCommand('Input.dispatchKeyEvent', {
type: 'keyUp',
key: event.key,
code: event.code,
keyCode: event.keyCode,
windowsVirtualKeyCode: event.keyCode,
modifiers: (event.shiftKey ? 8 : 0) | (event.ctrlKey ? 4 : 0) | (event.altKey ? 2 : 0) | (event.metaKey ? 1 : 0),
});
event.preventDefault();
}
});
window.addEventListener('paste', async (event) => {
const text = event.clipboardData.getData('text/plain');
if (text) {
await sendCdpCommand('Input.insertText', { text });
event.preventDefault();
}
});
// Close the connection when the page is closed
window.addEventListener('beforeunload', function() {
if (socket.readyState === WebSocket.OPEN) {
socket.close();
}
});
</script>
</body>
</html>