@blitzbrowser/blitzbrowser 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +25 -0
- package/.prettierrc +4 -0
- package/Dockerfile +26 -0
- package/LICENSE +201 -0
- package/README.md +1 -0
- package/dist/app.module.d.ts +2 -0
- package/dist/app.module.js +51 -0
- package/dist/app.module.js.map +1 -0
- package/dist/components/browser-instance.component.d.ts +53 -0
- package/dist/components/browser-instance.component.js +373 -0
- package/dist/components/browser-instance.component.js.map +1 -0
- package/dist/components/browser-instance.process.d.ts +1 -0
- package/dist/components/browser-instance.process.js +174 -0
- package/dist/components/browser-instance.process.js.map +1 -0
- package/dist/controllers/cdp.controller.d.ts +17 -0
- package/dist/controllers/cdp.controller.js +122 -0
- package/dist/controllers/cdp.controller.js.map +1 -0
- package/dist/controllers/pool-status.controller.d.ts +10 -0
- package/dist/controllers/pool-status.controller.js +38 -0
- package/dist/controllers/pool-status.controller.js.map +1 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +10 -0
- package/dist/main.js.map +1 -0
- package/dist/services/browser-pool.service.d.ts +36 -0
- package/dist/services/browser-pool.service.js +128 -0
- package/dist/services/browser-pool.service.js.map +1 -0
- package/dist/services/timezone.service.d.ts +6 -0
- package/dist/services/timezone.service.js +64 -0
- package/dist/services/timezone.service.js.map +1 -0
- package/dist/services/user-data.service.d.ts +15 -0
- package/dist/services/user-data.service.js +107 -0
- package/dist/services/user-data.service.js.map +1 -0
- package/dist/transforms/limit-stream.d.ts +7 -0
- package/dist/transforms/limit-stream.js +22 -0
- package/dist/transforms/limit-stream.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/docker-compose.yml +23 -0
- package/nest-cli.json +8 -0
- package/package.json +68 -0
- package/src/app.module.ts +39 -0
- package/src/components/browser-instance.component.ts +534 -0
- package/src/components/browser-instance.process.ts +193 -0
- package/src/controllers/cdp.controller.ts +122 -0
- package/src/controllers/pool-status.controller.ts +18 -0
- package/src/main.ts +9 -0
- package/src/services/browser-pool.service.ts +139 -0
- package/src/services/timezone.service.ts +42 -0
- package/src/services/user-data.service.ts +111 -0
- package/src/transforms/limit-stream.ts +30 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
services:
|
|
2
|
+
s3:
|
|
3
|
+
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
|
4
|
+
ports:
|
|
5
|
+
- "8001:9001"
|
|
6
|
+
- "8000:9000"
|
|
7
|
+
environment:
|
|
8
|
+
MINIO_ROOT_USER: admin
|
|
9
|
+
MINIO_ROOT_PASSWORD: admin123
|
|
10
|
+
command: server --console-address ":9001" /data
|
|
11
|
+
restart: always
|
|
12
|
+
blitzbrowser:
|
|
13
|
+
image: sha256:75beb3e809758401bf3520576083f4a8d8094628cb0d61058be56de7cb1fbe32
|
|
14
|
+
command: node ./dist/main.js
|
|
15
|
+
ports:
|
|
16
|
+
- "9999:9999"
|
|
17
|
+
environment:
|
|
18
|
+
S3_ENDPOINT: http://s3:9000
|
|
19
|
+
S3_ACCESS_KEY_ID: xOyDpwtTUn5P7lkWpbSP
|
|
20
|
+
S3_SECRET_ACCESS_KEY: XbCcsDW7oAY8bC82ptPOLLny2up4eZjg6MIqCYxP
|
|
21
|
+
S3_USER_DATA_BUCKET: user-data
|
|
22
|
+
shm_size: "2gb"
|
|
23
|
+
restart: always
|
package/nest-cli.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blitzbrowser/blitzbrowser",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Deploying, scaling and managing browsers at scale.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@aws-sdk/client-s3": "^3.911.0",
|
|
8
|
+
"@blitzbrowser/tunnel": "2.0.2",
|
|
9
|
+
"@nestjs/common": "^10.0.0",
|
|
10
|
+
"@nestjs/core": "^10.0.0",
|
|
11
|
+
"@nestjs/platform-express": "^10.0.0",
|
|
12
|
+
"axios": "^1.12.2",
|
|
13
|
+
"proxy-chain": "^2.5.9",
|
|
14
|
+
"puppeteer": "^24.6.0",
|
|
15
|
+
"reflect-metadata": "^0.2.0",
|
|
16
|
+
"rxjs": "^7.8.1",
|
|
17
|
+
"tar": "^7.5.1",
|
|
18
|
+
"ws": "^8.18.3",
|
|
19
|
+
"zod": "^4.3.5"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@nestjs/cli": "^10.0.0",
|
|
23
|
+
"@nestjs/schematics": "^10.0.0",
|
|
24
|
+
"@nestjs/testing": "^10.0.0",
|
|
25
|
+
"@types/express": "^4.17.17",
|
|
26
|
+
"@types/http-proxy": "^1.17.16",
|
|
27
|
+
"@types/jest": "^29.5.2",
|
|
28
|
+
"@types/node": "^20.3.1",
|
|
29
|
+
"@types/supertest": "^6.0.0",
|
|
30
|
+
"@types/ws": "^8.18.1",
|
|
31
|
+
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
32
|
+
"@typescript-eslint/parser": "^6.0.0",
|
|
33
|
+
"eslint": "^8.42.0",
|
|
34
|
+
"eslint-config-prettier": "^9.0.0",
|
|
35
|
+
"eslint-plugin-prettier": "^5.0.0",
|
|
36
|
+
"jest": "^29.5.0",
|
|
37
|
+
"prettier": "^3.0.0",
|
|
38
|
+
"source-map-support": "^0.5.21",
|
|
39
|
+
"supertest": "^6.3.3",
|
|
40
|
+
"ts-jest": "^29.1.0",
|
|
41
|
+
"ts-loader": "^9.4.3",
|
|
42
|
+
"ts-node": "^10.9.1",
|
|
43
|
+
"tsconfig-paths": "^4.2.0",
|
|
44
|
+
"typescript": "^5.1.3"
|
|
45
|
+
},
|
|
46
|
+
"jest": {
|
|
47
|
+
"moduleFileExtensions": [
|
|
48
|
+
"js",
|
|
49
|
+
"json",
|
|
50
|
+
"ts"
|
|
51
|
+
],
|
|
52
|
+
"rootDir": "src",
|
|
53
|
+
"testRegex": ".*\\.spec\\.ts$",
|
|
54
|
+
"transform": {
|
|
55
|
+
"^.+\\.(t|j)s$": "ts-jest"
|
|
56
|
+
},
|
|
57
|
+
"collectCoverageFrom": [
|
|
58
|
+
"**/*.(t|j)s"
|
|
59
|
+
],
|
|
60
|
+
"coverageDirectory": "../coverage",
|
|
61
|
+
"testEnvironment": "node"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "rm -r ./dist && nest build",
|
|
65
|
+
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
66
|
+
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
3
|
+
import { PoolStatusController } from './controllers/pool-status.controller';
|
|
4
|
+
import { UserDataS3Service, UserDataService } from './services/user-data.service';
|
|
5
|
+
import { BrowserPoolService } from './services/browser-pool.service';
|
|
6
|
+
import { TimezoneService } from './services/timezone.service';
|
|
7
|
+
import { CDPController } from './controllers/cdp.controller';
|
|
8
|
+
|
|
9
|
+
@Module({
|
|
10
|
+
imports: [],
|
|
11
|
+
controllers: [
|
|
12
|
+
CDPController,
|
|
13
|
+
PoolStatusController,
|
|
14
|
+
],
|
|
15
|
+
providers: [
|
|
16
|
+
{
|
|
17
|
+
provide: S3Client,
|
|
18
|
+
useFactory: async () => {
|
|
19
|
+
return new S3Client({
|
|
20
|
+
region: 'auto',
|
|
21
|
+
endpoint: process.env.S3_ENDPOINT,
|
|
22
|
+
credentials: {
|
|
23
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
|
24
|
+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
|
25
|
+
},
|
|
26
|
+
forcePathStyle: true,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
provide: UserDataService,
|
|
32
|
+
useClass: UserDataS3Service,
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
BrowserPoolService,
|
|
36
|
+
TimezoneService
|
|
37
|
+
],
|
|
38
|
+
})
|
|
39
|
+
export class AppModule { }
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
import { ChildProcess, spawn } from 'child_process';
|
|
3
|
+
import * as ProxyChain from 'proxy-chain';
|
|
4
|
+
import * as EventEmitter from 'events';
|
|
5
|
+
import { WebSocket } from 'ws';
|
|
6
|
+
import { Channel, Tunnel } from '@blitzbrowser/tunnel';
|
|
7
|
+
import { ModuleRef } from '@nestjs/core';
|
|
8
|
+
import { UserDataService } from 'src/services/user-data.service';
|
|
9
|
+
import * as fsPromise from 'fs/promises';
|
|
10
|
+
import { TimezoneService } from 'src/services/timezone.service';
|
|
11
|
+
import { BrowserPoolService, BrowserPoolStatus } from 'src/services/browser-pool.service';
|
|
12
|
+
|
|
13
|
+
interface Stats {
|
|
14
|
+
trgRxBytes: number;
|
|
15
|
+
trgTxBytes: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class PortPool {
|
|
19
|
+
|
|
20
|
+
#next_port_to_claim = 13000;
|
|
21
|
+
|
|
22
|
+
readonly #available_ports: number[] = [];
|
|
23
|
+
|
|
24
|
+
constructor(starting_port: number) {
|
|
25
|
+
this.#next_port_to_claim = starting_port;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getAvailablePort() {
|
|
29
|
+
if (this.#available_ports.length === 0) {
|
|
30
|
+
return this.#next_port_to_claim++;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return this.#available_ports.shift();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
releasePort(port: number) {
|
|
37
|
+
if (typeof port !== 'number') {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.#available_ports.push(port);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BrowserInstanceEvents {
|
|
47
|
+
cdp_terminated: [];
|
|
48
|
+
terminated: [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ConnectionOptions {
|
|
52
|
+
timezone?: string;
|
|
53
|
+
proxy_url?: string;
|
|
54
|
+
user_data_id?: string;
|
|
55
|
+
user_data_read_only?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Connection Options to use to launch the browser instance
|
|
60
|
+
*/
|
|
61
|
+
export interface ConnectionOptionsEvent {
|
|
62
|
+
type: 'CONNECTION_OPTIONS';
|
|
63
|
+
options: ConnectionOptions;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Request to terminate the CDP.
|
|
68
|
+
*/
|
|
69
|
+
interface CDPCloseEvent {
|
|
70
|
+
type: 'CDP_CLOSE';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* CDP connection is terminated.
|
|
75
|
+
*/
|
|
76
|
+
interface CDPTerminatedEvent {
|
|
77
|
+
type: 'CDP_TERMINATED';
|
|
78
|
+
status: BrowserInstanceStatus;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Browser instance status update
|
|
83
|
+
*/
|
|
84
|
+
interface BrowserInstanceStatusEvent {
|
|
85
|
+
type: 'BROWSER_INSTANCE_STATUS';
|
|
86
|
+
status: BrowserInstanceStatus;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface BrowserInstanceRequestEvent {
|
|
90
|
+
type: 'BROWSER_INSTANCE_REQUEST';
|
|
91
|
+
url: string;
|
|
92
|
+
bytes_downloaded: number;
|
|
93
|
+
bytes_uploaded: number;
|
|
94
|
+
created_at: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type BrowserInstanceEvent = ConnectionOptionsEvent | CDPCloseEvent | CDPTerminatedEvent | BrowserInstanceStatusEvent | BrowserInstanceRequestEvent;
|
|
98
|
+
|
|
99
|
+
export interface BrowserInstanceStatus {
|
|
100
|
+
browser_pool: BrowserPoolStatus;
|
|
101
|
+
|
|
102
|
+
// Order of events that should happen in happy path
|
|
103
|
+
connected_at: string | undefined;
|
|
104
|
+
preparation_tasks_started_at: string | undefined;
|
|
105
|
+
browser_process_launching_at: string | undefined;
|
|
106
|
+
browser_process_launched_at: string | undefined;
|
|
107
|
+
browser_process_cdp_connected_at: string | undefined;
|
|
108
|
+
browser_process_cdp_terminated_at: string | undefined;
|
|
109
|
+
completion_tasks_started_at: string | undefined;
|
|
110
|
+
|
|
111
|
+
// Can happen anytime
|
|
112
|
+
cdp_close_event_at: string | undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class BrowserInstance extends EventEmitter<BrowserInstanceEvents> {
|
|
116
|
+
|
|
117
|
+
static readonly CDP_CHANNEL_ID = 2;
|
|
118
|
+
static readonly EVENT_CHANNEL_ID = 3;
|
|
119
|
+
|
|
120
|
+
static #CDP_PORT_POOL = new PortPool(13000);
|
|
121
|
+
|
|
122
|
+
readonly #logger: Logger;
|
|
123
|
+
|
|
124
|
+
readonly #browser_pool_service: BrowserPoolService;
|
|
125
|
+
|
|
126
|
+
#browser_instance_process: ChildProcess;
|
|
127
|
+
|
|
128
|
+
#proxy_server: ProxyChain.Server;
|
|
129
|
+
|
|
130
|
+
#cdp_websocket: WebSocket;
|
|
131
|
+
|
|
132
|
+
#connected_at: string | undefined;
|
|
133
|
+
#preparation_tasks_started_at: string | undefined;
|
|
134
|
+
#browser_process_launching_at: string | undefined;
|
|
135
|
+
#browser_process_launched_at: string | undefined;
|
|
136
|
+
#browser_process_cdp_connected_at: string | undefined;
|
|
137
|
+
#browser_process_cdp_terminated_at: string | undefined;
|
|
138
|
+
#completion_tasks_started_at: string | undefined;
|
|
139
|
+
#cdp_close_event_at: string | undefined;
|
|
140
|
+
|
|
141
|
+
readonly #user_data_folder: string;
|
|
142
|
+
|
|
143
|
+
#connection_options: ConnectionOptions | undefined;
|
|
144
|
+
|
|
145
|
+
#tunnel: Tunnel;
|
|
146
|
+
#event_channel: Channel;
|
|
147
|
+
#cdp_channel: Channel;
|
|
148
|
+
|
|
149
|
+
#timezone: string;
|
|
150
|
+
#cdp_port: number;
|
|
151
|
+
|
|
152
|
+
constructor(
|
|
153
|
+
readonly id: string,
|
|
154
|
+
readonly module_ref: ModuleRef
|
|
155
|
+
) {
|
|
156
|
+
super();
|
|
157
|
+
this.#browser_pool_service = this.module_ref.get(BrowserPoolService);
|
|
158
|
+
this.#logger = new Logger(`${BrowserInstance.name}|${id}`);
|
|
159
|
+
this.#user_data_folder = `/home/pptruser/user-data/${id}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get in_use() {
|
|
163
|
+
return typeof this.#connected_at === 'string';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
get cdp_terminated() {
|
|
167
|
+
return typeof this.#browser_process_cdp_terminated_at === 'string';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
connectTunnel(tunnel: Tunnel) {
|
|
171
|
+
if (this.in_use) {
|
|
172
|
+
throw new Error(`Browser instance ${this.id} is already in use.`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.#connected_at = new Date().toISOString();
|
|
176
|
+
this.#tunnel = tunnel;
|
|
177
|
+
|
|
178
|
+
this.#tunnel.on('closed', () => {
|
|
179
|
+
this.#logger.log('Tunnel closed');
|
|
180
|
+
this.close();
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
this.#logger.log('Connecting tunnel.');
|
|
184
|
+
|
|
185
|
+
this.#event_channel = this.#tunnel.createChannel(BrowserInstance.EVENT_CHANNEL_ID, async (data) => {
|
|
186
|
+
const event: BrowserInstanceEvent = JSON.parse(data.toString('utf8'));
|
|
187
|
+
|
|
188
|
+
switch (event.type) {
|
|
189
|
+
case 'CONNECTION_OPTIONS':
|
|
190
|
+
this.#connection_options = event.options;
|
|
191
|
+
this.#startPreparationTasks();
|
|
192
|
+
break;
|
|
193
|
+
case 'CDP_CLOSE':
|
|
194
|
+
this.#cdp_close_event_at = new Date().toISOString();
|
|
195
|
+
this.#logger.log('Received CDP Close event');
|
|
196
|
+
this.close();
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this.#sendBrowserInstanceStatus();
|
|
202
|
+
|
|
203
|
+
this.once('cdp_terminated', () => {
|
|
204
|
+
this.#sendBrowserInstanceStatus();
|
|
205
|
+
this.#event_channel.send(JSON.stringify({ type: 'CDP_TERMINATED', status: this.status } satisfies CDPTerminatedEvent));
|
|
206
|
+
this.#logger.log('CDP terminated, will now close.')
|
|
207
|
+
this.close();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
this.once('terminated', () => {
|
|
211
|
+
// Release after terminated. We need to make sure the browser process is killed before reusing the port. Preventing collision.
|
|
212
|
+
BrowserInstance.#CDP_PORT_POOL.releasePort(this.#cdp_port);
|
|
213
|
+
this.#logger.log('Released CDP port');
|
|
214
|
+
|
|
215
|
+
this.#sendBrowserInstanceStatus();
|
|
216
|
+
this.#tunnel.close();
|
|
217
|
+
|
|
218
|
+
this.#logger.log('Closed tunnel.');
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async #startPreparationTasks() {
|
|
223
|
+
try {
|
|
224
|
+
this.#preparation_tasks_started_at = new Date().toISOString();
|
|
225
|
+
|
|
226
|
+
this.#logger.log('Starting preparation tasks');
|
|
227
|
+
|
|
228
|
+
await fsPromise.mkdir(this.#user_data_folder, { recursive: true });
|
|
229
|
+
|
|
230
|
+
this.#cdp_port = BrowserInstance.#CDP_PORT_POOL.getAvailablePort();
|
|
231
|
+
|
|
232
|
+
await Promise.all([
|
|
233
|
+
this.#startProxyServer(),
|
|
234
|
+
this.#updateTimezone(),
|
|
235
|
+
(async () => {
|
|
236
|
+
if (this.#connection_options?.user_data_id) {
|
|
237
|
+
await this.#downloadUserData();
|
|
238
|
+
}
|
|
239
|
+
})()
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
await this.#launchProcess();
|
|
243
|
+
|
|
244
|
+
this.#sendBrowserInstanceStatus();
|
|
245
|
+
} catch (e) {
|
|
246
|
+
this.#logger.error('Error while doing preparation tasks', e?.stack || e);
|
|
247
|
+
this.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async #downloadUserData() {
|
|
252
|
+
if (!this.#connection_options.user_data_id) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
await this.module_ref.get(UserDataService).downloadUserData(
|
|
257
|
+
this.#connection_options.user_data_id,
|
|
258
|
+
this.#user_data_folder
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
readonly #proxy_connections: { [connection_id: number]: { url: string; } } = {};
|
|
263
|
+
|
|
264
|
+
async #startProxyServer() {
|
|
265
|
+
if (this.#proxy_server) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return new Promise((res, rej) => {
|
|
270
|
+
this.#proxy_server = new ProxyChain.Server({
|
|
271
|
+
port: 0,
|
|
272
|
+
host: '127.0.0.1',
|
|
273
|
+
verbose: false,
|
|
274
|
+
prepareRequestFunction: async ({ request, connectionId }) => {
|
|
275
|
+
this.#proxy_connections[connectionId] = { url: request.url };
|
|
276
|
+
|
|
277
|
+
if (!this.in_use || !this.#connection_options?.proxy_url) {
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
upstreamProxyUrl: this.#connection_options.proxy_url
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
this.#proxy_server.listen((err) => {
|
|
288
|
+
if (err) {
|
|
289
|
+
rej(err);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.#proxy_server.on('connectionClosed', ({ connectionId, stats }: { connectionId: number; stats: Stats }) => {
|
|
294
|
+
if (this.#event_channel) {
|
|
295
|
+
const proxy_connection = this.#proxy_connections[connectionId];
|
|
296
|
+
|
|
297
|
+
if (proxy_connection) {
|
|
298
|
+
this.#event_channel.send(JSON.stringify({
|
|
299
|
+
type: 'BROWSER_INSTANCE_REQUEST',
|
|
300
|
+
url: proxy_connection.url,
|
|
301
|
+
bytes_downloaded: stats.trgRxBytes,
|
|
302
|
+
bytes_uploaded: stats.trgTxBytes,
|
|
303
|
+
created_at: new Date().toISOString()
|
|
304
|
+
} satisfies BrowserInstanceRequestEvent));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
delete this.#proxy_connections[connectionId];
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
res(undefined);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async #updateTimezone() {
|
|
317
|
+
if (this.#connection_options?.timezone) {
|
|
318
|
+
this.#timezone = this.#connection_options.timezone;
|
|
319
|
+
} else if (this.#connection_options?.proxy_url) {
|
|
320
|
+
this.#timezone = await this.module_ref.get(TimezoneService).getProxyTimezone(this.#connection_options.proxy_url);
|
|
321
|
+
} else {
|
|
322
|
+
this.#timezone = await this.module_ref.get(TimezoneService).getDefaultTimezone();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async close() {
|
|
327
|
+
if (typeof this.#completion_tasks_started_at === 'string') {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
this.#completion_tasks_started_at = new Date().toISOString();
|
|
332
|
+
|
|
333
|
+
this.#sendBrowserInstanceStatus();
|
|
334
|
+
|
|
335
|
+
this.#logger.log('Starting completion tasks.');
|
|
336
|
+
|
|
337
|
+
if (this.#isBrowserProcessAlive()) {
|
|
338
|
+
this.#logger.log('Killing process');
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
await new Promise((res) => {
|
|
342
|
+
this.#browser_instance_process.once('exit', () => {
|
|
343
|
+
res(undefined);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
this.#browser_instance_process.kill();
|
|
347
|
+
});
|
|
348
|
+
} catch (e) {
|
|
349
|
+
this.#logger.error('Error while killing process', e?.stack);
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
this.#logger.log('Process already killed.');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (this.#cdp_websocket) {
|
|
356
|
+
this.#logger.log('Closing cdp websocket.');
|
|
357
|
+
this.#cdp_websocket.close();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (this.#proxy_server) {
|
|
361
|
+
this.#logger.log('Closing proxy server.');
|
|
362
|
+
try {
|
|
363
|
+
await this.#proxy_server.close(true);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
this.#logger.error('Error while closing proxy server', e?.stack);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
await this.#uploadUserData();
|
|
371
|
+
} catch (e) {
|
|
372
|
+
this.#logger.error('Error while uploading user data', e?.stack || e);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
this.#logger.log('Deleting user data.');
|
|
377
|
+
await fsPromise.rm(this.#user_data_folder, { recursive: true, force: true, maxRetries: 3, retryDelay: 250 });
|
|
378
|
+
} catch (e) {
|
|
379
|
+
this.#logger.error('Error while deleting user data', e?.stack || e);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
this.#logger.log('Completion tasks terminated.');
|
|
383
|
+
this.emit('terminated');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async #uploadUserData() {
|
|
387
|
+
if (!this.#connection_options?.user_data_id || this.#connection_options?.user_data_read_only === true) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
this.#logger.log('Uploading user data.');
|
|
392
|
+
|
|
393
|
+
await this.module_ref.get(UserDataService).uploadUserData(
|
|
394
|
+
this.#connection_options.user_data_id,
|
|
395
|
+
this.#user_data_folder
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async #launchProcess() {
|
|
400
|
+
this.#browser_process_launching_at = new Date().toISOString();
|
|
401
|
+
|
|
402
|
+
this.#browser_instance_process = spawn(
|
|
403
|
+
`tini`,
|
|
404
|
+
['-s', `--`, `node`, `./dist/components/browser-instance.process.js`],
|
|
405
|
+
{
|
|
406
|
+
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
|
407
|
+
env: {
|
|
408
|
+
...process.env,
|
|
409
|
+
CDP_PORT: `${this.#cdp_port}`,
|
|
410
|
+
PROXY_SERVER_PORT: `${this.#proxy_server.port}`,
|
|
411
|
+
USER_DATA_FOLDER: this.#user_data_folder,
|
|
412
|
+
TZ: this.#timezone
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
this.#browser_instance_process.stdout.on('data', (data) => {
|
|
418
|
+
(data.toString() as string).split('\n').map(s => s.trim()).filter(s => s !== '').forEach(log => {
|
|
419
|
+
this.#logger.log(`stdout: ${log}`);
|
|
420
|
+
})
|
|
421
|
+
});
|
|
422
|
+
this.#browser_instance_process.stderr.on('data', (data) => {
|
|
423
|
+
(data.toString() as string).split('\n').map(s => s.trim()).filter(s => s !== '').forEach(log => {
|
|
424
|
+
this.#logger.error(`stderr: ${log}`);
|
|
425
|
+
})
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await new Promise((res, rej) => {
|
|
429
|
+
this.#browser_instance_process.on('spawn', () => {
|
|
430
|
+
this.#logger.log('Process spawned');
|
|
431
|
+
res(undefined);
|
|
432
|
+
});
|
|
433
|
+
this.#browser_instance_process.on('error', err => {
|
|
434
|
+
rej(err);
|
|
435
|
+
})
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
this.#browser_process_launched_at = new Date().toISOString();
|
|
439
|
+
|
|
440
|
+
this.#logger.log('Process launched');
|
|
441
|
+
|
|
442
|
+
this.#browser_instance_process.once('exit', (e, i) => {
|
|
443
|
+
this.#logger.log(`Process exited ${e} ${i}`);
|
|
444
|
+
this.close();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const start = Date.now();
|
|
448
|
+
|
|
449
|
+
do {
|
|
450
|
+
try {
|
|
451
|
+
const response = await fetch(`http://127.0.0.1:${this.#cdp_port}/json/version`, { signal: AbortSignal.timeout(350) });
|
|
452
|
+
|
|
453
|
+
if (response.status !== 200) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const data = await response.json();
|
|
458
|
+
|
|
459
|
+
if (!data.webSocketDebuggerUrl) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
this.#cdp_websocket = new WebSocket(data.webSocketDebuggerUrl);
|
|
464
|
+
|
|
465
|
+
await new Promise((res, rej) => {
|
|
466
|
+
this.#cdp_websocket.on('error', err => {
|
|
467
|
+
this.#logger.error('Error with CDP websocket', err?.stack || err);
|
|
468
|
+
rej(err);
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
this.#cdp_websocket.on('open', () => {
|
|
472
|
+
this.#browser_process_cdp_connected_at = new Date().toISOString();
|
|
473
|
+
this.#logger.log('CDP websocket connected');
|
|
474
|
+
this.#cdp_websocket.on('close', (code, reason) => {
|
|
475
|
+
this.#browser_process_cdp_terminated_at = new Date().toISOString();
|
|
476
|
+
this.#logger.log(`CDP Terminated ${code} ${reason.toString()}`);
|
|
477
|
+
this.emit('cdp_terminated');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
this.#cdp_channel = this.#tunnel.createChannel(BrowserInstance.CDP_CHANNEL_ID, data => {
|
|
481
|
+
this.#cdp_websocket.send(data.toString('utf8'), { binary: false });
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
this.#cdp_websocket.on('message', (data) => {
|
|
485
|
+
this.#cdp_channel.send(data.toString('utf8'));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
res(undefined);
|
|
489
|
+
})
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return;
|
|
493
|
+
} catch (e) {
|
|
494
|
+
await new Promise(r => setTimeout(r, 20));
|
|
495
|
+
}
|
|
496
|
+
} while (Date.now() - start < 8000); // 8 seconds
|
|
497
|
+
|
|
498
|
+
this.#logger.error('CDP websocket failed to connect. Closing.');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
get status() {
|
|
502
|
+
return {
|
|
503
|
+
browser_pool: this.#browser_pool_service.status,
|
|
504
|
+
connected_at: this.#connected_at,
|
|
505
|
+
preparation_tasks_started_at: this.#preparation_tasks_started_at,
|
|
506
|
+
browser_process_launching_at: this.#browser_process_launching_at,
|
|
507
|
+
browser_process_launched_at: this.#browser_process_launched_at,
|
|
508
|
+
browser_process_cdp_connected_at: this.#browser_process_cdp_connected_at,
|
|
509
|
+
browser_process_cdp_terminated_at: this.#browser_process_cdp_terminated_at,
|
|
510
|
+
completion_tasks_started_at: this.#completion_tasks_started_at,
|
|
511
|
+
cdp_close_event_at: this.#cdp_close_event_at,
|
|
512
|
+
} satisfies BrowserInstanceStatus;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#sendBrowserInstanceStatus() {
|
|
516
|
+
if (this.#event_channel) {
|
|
517
|
+
this.#event_channel.send(JSON.stringify({ type: 'BROWSER_INSTANCE_STATUS', status: this.status } satisfies BrowserInstanceStatusEvent));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#isBrowserProcessAlive() {
|
|
522
|
+
if (!this.#browser_instance_process) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
process.kill(this.#browser_instance_process.pid, 0);
|
|
528
|
+
return true;
|
|
529
|
+
} catch (e) {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
}
|