@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.
Files changed (51) hide show
  1. package/.eslintrc.js +25 -0
  2. package/.prettierrc +4 -0
  3. package/Dockerfile +26 -0
  4. package/LICENSE +201 -0
  5. package/README.md +1 -0
  6. package/dist/app.module.d.ts +2 -0
  7. package/dist/app.module.js +51 -0
  8. package/dist/app.module.js.map +1 -0
  9. package/dist/components/browser-instance.component.d.ts +53 -0
  10. package/dist/components/browser-instance.component.js +373 -0
  11. package/dist/components/browser-instance.component.js.map +1 -0
  12. package/dist/components/browser-instance.process.d.ts +1 -0
  13. package/dist/components/browser-instance.process.js +174 -0
  14. package/dist/components/browser-instance.process.js.map +1 -0
  15. package/dist/controllers/cdp.controller.d.ts +17 -0
  16. package/dist/controllers/cdp.controller.js +122 -0
  17. package/dist/controllers/cdp.controller.js.map +1 -0
  18. package/dist/controllers/pool-status.controller.d.ts +10 -0
  19. package/dist/controllers/pool-status.controller.js +38 -0
  20. package/dist/controllers/pool-status.controller.js.map +1 -0
  21. package/dist/main.d.ts +1 -0
  22. package/dist/main.js +10 -0
  23. package/dist/main.js.map +1 -0
  24. package/dist/services/browser-pool.service.d.ts +36 -0
  25. package/dist/services/browser-pool.service.js +128 -0
  26. package/dist/services/browser-pool.service.js.map +1 -0
  27. package/dist/services/timezone.service.d.ts +6 -0
  28. package/dist/services/timezone.service.js +64 -0
  29. package/dist/services/timezone.service.js.map +1 -0
  30. package/dist/services/user-data.service.d.ts +15 -0
  31. package/dist/services/user-data.service.js +107 -0
  32. package/dist/services/user-data.service.js.map +1 -0
  33. package/dist/transforms/limit-stream.d.ts +7 -0
  34. package/dist/transforms/limit-stream.js +22 -0
  35. package/dist/transforms/limit-stream.js.map +1 -0
  36. package/dist/tsconfig.build.tsbuildinfo +1 -0
  37. package/docker-compose.yml +23 -0
  38. package/nest-cli.json +8 -0
  39. package/package.json +68 -0
  40. package/src/app.module.ts +39 -0
  41. package/src/components/browser-instance.component.ts +534 -0
  42. package/src/components/browser-instance.process.ts +193 -0
  43. package/src/controllers/cdp.controller.ts +122 -0
  44. package/src/controllers/pool-status.controller.ts +18 -0
  45. package/src/main.ts +9 -0
  46. package/src/services/browser-pool.service.ts +139 -0
  47. package/src/services/timezone.service.ts +42 -0
  48. package/src/services/user-data.service.ts +111 -0
  49. package/src/transforms/limit-stream.ts +30 -0
  50. package/tsconfig.build.json +4 -0
  51. 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
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/nest-cli",
3
+ "collection": "@nestjs/schematics",
4
+ "sourceRoot": "src",
5
+ "compilerOptions": {
6
+ "deleteOutDir": true
7
+ }
8
+ }
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
+ }