@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,193 @@
1
+ import { ChildProcess, spawn } from 'child_process';
2
+ import * as fsPromise from 'fs/promises';
3
+ import { exit } from 'process';
4
+ import puppeteer, { Browser } from 'puppeteer';
5
+
6
+ let SIGTERM_RECEIVED = false;
7
+
8
+ class BrowserInstanceProcess {
9
+
10
+ readonly #cdp_port = parseInt(process.env.CDP_PORT);
11
+ readonly #proxy_server_port = parseInt(process.env.PROXY_SERVER_PORT);
12
+ readonly #timezone = process.env.TZ;
13
+ readonly #user_data_folder = process.env.USER_DATA_FOLDER;
14
+
15
+ #xvfb_process: ChildProcess;
16
+ #puppeteer_process: Browser;
17
+
18
+ #is_closing = false;
19
+ #client_disconnected = false;
20
+
21
+ get launched() {
22
+ return this.#xvfb_process !== undefined && this.#puppeteer_process !== undefined;
23
+ }
24
+
25
+ async launch() {
26
+ if (SIGTERM_RECEIVED) {
27
+ console.log(`Can't launch, received sigterm before launching.`);
28
+ await this.close();
29
+ return;
30
+ }
31
+
32
+ console.log('Launching');
33
+
34
+ try {
35
+ const display_id = this.#cdp_port; // We use the port to guarantee only one instance has this screen
36
+ const display = `:${display_id}`;
37
+ const display_lock_file = `/tmp/.X${display_id}-lock`;
38
+
39
+ // When container is restarting, xvfb locks are not always released on file system.
40
+ await fsPromise.rm(display_lock_file, { force: true });
41
+
42
+ console.log('Creating xvfb process');
43
+ this.#xvfb_process = spawn('Xvfb', [display, '-screen', '0', '1920x1080x24'], { stdio: 'inherit' });
44
+
45
+ console.log('Creating puppeteer process');
46
+ this.#puppeteer_process = await puppeteer.launch({
47
+ headless: false,
48
+ executablePath: puppeteer.executablePath(),
49
+ userDataDir: this.#user_data_folder,
50
+ dumpio: true,
51
+ handleSIGTERM: false,
52
+ args: [
53
+ `--remote-debugging-port=${this.#cdp_port}`,
54
+ '--remote-debugging-address=0.0.0.0',
55
+ '--no-first-run',
56
+ '--no-default-browser-check',
57
+ `--display=${display}`,
58
+ '--disable-breakpad', // Disables crash reporting
59
+ '--disable-component-update', // Disables updates to internal components
60
+ '--disable-print-preview', // Disables print preview
61
+ '--disable-domain-reliability', // Disables domain reliability service(client-side reliability monitoring system for Google sites)
62
+ '--disk-cache-dir=/dev/null', // Prevents disk caching
63
+ '--no-pings', // Disables hyperlink auditing pings
64
+ '--disable-notifications', // Prevents web push notifications
65
+ '--disable-features=TranslateUI', // Disables the translate UI
66
+ '--disable-background-networking',
67
+ '--disable-sync',
68
+ '--disable-extensions',
69
+ '--disable-signin-promo',
70
+ '--disable-gpu',
71
+ '--metrics-recording-only', // Record metrics, but doesn't report them
72
+ '--disable-features=PersistentHistograms', // Prevents generation of BrowserMetrics files on disk
73
+ `--start-maximized`,
74
+ `--proxy-server=http://127.0.0.1:${this.#proxy_server_port}`,
75
+ process.env.DISABLE_SHM === 'true' ? '--disable-dev-shm-usage' : undefined
76
+ ].filter(s => typeof s === 'string'),
77
+ env: {
78
+ TZ: this.#timezone
79
+ },
80
+ });
81
+ console.log('Created puppeteer process.');
82
+
83
+ this.#puppeteer_process.on('disconnected', () => {
84
+ this.#client_disconnected = true;
85
+ console.log('Client disconnected');
86
+ });
87
+
88
+ const interval_id = setInterval(() => {
89
+ if (this.#puppeteer_process.connected) {
90
+ return;
91
+ }
92
+
93
+ clearInterval(interval_id);
94
+
95
+ if (this.#is_closing) {
96
+ return;
97
+ }
98
+
99
+ console.log(`Puppeteer not connected.`);
100
+
101
+ this.close();
102
+ }, 200);
103
+ } catch (e) {
104
+ console.log('Error while launching processes', e);
105
+ this.close();
106
+ } finally {
107
+ if (SIGTERM_RECEIVED) {
108
+ this.close();
109
+ return;
110
+ }
111
+ }
112
+ }
113
+
114
+ async close() {
115
+ if (this.#is_closing) {
116
+ return;
117
+ }
118
+
119
+ this.#is_closing = true;
120
+
121
+ console.log('Closing.');
122
+
123
+ // We need to close puppeteer first. All other processes HAVE to be closed after. Otherwise it crashes puppeteer.
124
+ if (this.#puppeteer_process) {
125
+ try {
126
+ // If the client disconnected, it means puppeteer is already closing the browser process.
127
+ // Calling close() if already closing corrupt the user data. I lost 5 days over this concurrent closing issue.
128
+ if (!this.#client_disconnected && this.#puppeteer_process.connected) {
129
+ await this.#puppeteer_process.close();
130
+ }
131
+
132
+ await this.#waitGoogleChromeKilled();
133
+ } catch (e) {
134
+ console.error('Error while killing puppeteer', e?.stack);
135
+ }
136
+ }
137
+
138
+ if (this.#xvfb_process) {
139
+ try {
140
+ this.#xvfb_process.kill();
141
+ } catch (e) {
142
+ console.error('Error while killing xvfb process', e?.stack);
143
+ }
144
+ }
145
+
146
+ exit(0);
147
+ }
148
+
149
+ async #waitGoogleChromeKilled() {
150
+ let count = 0;
151
+
152
+ await new Promise((res) => {
153
+ const interval_id = setInterval(() => {
154
+ if (count === 60) { // 3 seconds
155
+ clearInterval(interval_id);
156
+ res(undefined);
157
+ return;
158
+ }
159
+
160
+ const ps = spawn('ps', ['ax']);
161
+
162
+ ps.on('exit', () => {
163
+ count++;
164
+ });
165
+
166
+ ps.stdout.on('data', (data: Buffer) => {
167
+ const message = data.toString();
168
+ if (!message.includes(this.#user_data_folder)) {
169
+ clearInterval(interval_id);
170
+ res(undefined);
171
+ }
172
+ })
173
+ }, 50);
174
+ });
175
+ }
176
+ }
177
+
178
+ let browser_instance_process: BrowserInstanceProcess;
179
+
180
+ process.on('SIGTERM', () => {
181
+ SIGTERM_RECEIVED = true;
182
+ console.log('Received SIGTERM');
183
+
184
+ // If we receive sigterm while launching processes, we are going to wait for processes to finish launching.
185
+ // It guarantees that google chrome fully launched before killing it.
186
+ if (browser_instance_process && browser_instance_process.launched) {
187
+ browser_instance_process.close();
188
+ }
189
+ });
190
+
191
+ browser_instance_process = new BrowserInstanceProcess();
192
+
193
+ browser_instance_process.launch();
@@ -0,0 +1,122 @@
1
+ import { Controller, Logger, OnModuleInit } from '@nestjs/common';
2
+ import { HttpAdapterHost } from '@nestjs/core';
3
+ import { ExpressAdapter } from '@nestjs/platform-express';
4
+ import { BrowserInstance, ConnectionOptionsEvent } from 'src/components/browser-instance.component';
5
+ import { BrowserPoolService } from 'src/services/browser-pool.service';
6
+ import { WebSocketServer } from 'ws';
7
+ import z from 'zod';
8
+ import { Message, Tunnel } from '@blitzbrowser/tunnel';
9
+
10
+ export const PROXY_URL_QUERY_PARAM = 'proxyUrl';
11
+ export const TIMEZONE_QUERY_PARAM = 'timezone';
12
+ export const USER_DATA_ID_QUERY_PARAM = 'userDataId';
13
+ export const USER_DATA_READ_ONLY_QUERY_PARAM = 'userDataReadOnly';
14
+
15
+ const ConnectionOptionQueryParams = z.object({
16
+ proxy_url: z.url().optional(),
17
+ timezone: z.string().optional(),
18
+ user_data_id: z.string().optional(),
19
+ user_data_read_only: z.boolean().optional().default(false),
20
+ });
21
+
22
+ type ConnectionOptionQueryParams = z.infer<typeof ConnectionOptionQueryParams>;
23
+
24
+ @Controller()
25
+ export class CDPController implements OnModuleInit {
26
+
27
+ static readonly INTERNAL_SERVER_ERROR_CODE = 4000;
28
+ static readonly BAD_REQUEST_CODE = 4002;
29
+
30
+ readonly #logger = new Logger(CDPController.name);
31
+
32
+ constructor(
33
+ private readonly browser_pool_service: BrowserPoolService,
34
+ private readonly http_adapter_host: HttpAdapterHost,
35
+ ) { }
36
+
37
+ async onModuleInit() {
38
+ const websocket_server = new WebSocketServer({ server: (this.http_adapter_host.httpAdapter as ExpressAdapter).getHttpServer() });
39
+
40
+ websocket_server.on('connection', (cdp_websocket_client, req) => {
41
+ try {
42
+ const url = new URL(`http://127.0.0.1${req.url}`);
43
+ const parsed_connection_options = this.#parseConnectionOptionQueryParams(url);
44
+
45
+ if (!parsed_connection_options.success) {
46
+ cdp_websocket_client.close(CDPController.BAD_REQUEST_CODE, parsed_connection_options.error.message.trim());
47
+ return;
48
+ }
49
+
50
+ const browser_instance = this.browser_pool_service.createBrowserInstance();
51
+
52
+ const tunnel = new Tunnel();
53
+
54
+ tunnel.on('message', message => {
55
+ if (message.channel_id === BrowserInstance.CDP_CHANNEL_ID) {
56
+ cdp_websocket_client.send(message.data.toString('utf8'), { binary: false });
57
+ }
58
+ });
59
+
60
+ tunnel.once('closed', () => {
61
+ cdp_websocket_client.close();
62
+ })
63
+
64
+ cdp_websocket_client.on('message', message => {
65
+ tunnel.receiveMessage(Message.of(BrowserInstance.CDP_CHANNEL_ID, message.toString('utf8')));
66
+ });
67
+
68
+ const ping_interval_id = setInterval(() => {
69
+ cdp_websocket_client.ping();
70
+ }, 3000);
71
+
72
+ cdp_websocket_client.on('close', () => {
73
+ clearInterval(ping_interval_id);
74
+ tunnel.close();
75
+ });
76
+
77
+ cdp_websocket_client.on('error', err => {
78
+ this.#logger.error(`Error with cdp websocket.`, err?.stack || err);
79
+ });
80
+
81
+ browser_instance.connectTunnel(tunnel);
82
+
83
+ tunnel.receiveMessage(Message.of(BrowserInstance.EVENT_CHANNEL_ID, JSON.stringify({
84
+ type: 'CONNECTION_OPTIONS',
85
+ options: {
86
+ ...parsed_connection_options.data
87
+ }
88
+ } satisfies ConnectionOptionsEvent)));
89
+
90
+ this.#logger.log('Sent connection options');
91
+ } catch (e) {
92
+ this.#logger.error(`Error while handling client websocket.`, e?.stack || e)
93
+
94
+ cdp_websocket_client.close(CDPController.INTERNAL_SERVER_ERROR_CODE, e?.stack || e);
95
+ return;
96
+ }
97
+ });
98
+
99
+ websocket_server.once('close', () => {
100
+ this.browser_pool_service.shutdown();
101
+ });
102
+
103
+ websocket_server.once('error', (err) => {
104
+ this.#logger.error('Error with server websocket.', err?.stack || err);
105
+ this.browser_pool_service.shutdown();
106
+ });
107
+ }
108
+
109
+ #parseConnectionOptionQueryParams(url: URL) {
110
+ return ConnectionOptionQueryParams.safeParse({
111
+ timezone: this.getQueryParamValue(TIMEZONE_QUERY_PARAM, url),
112
+ proxy_url: this.getQueryParamValue(PROXY_URL_QUERY_PARAM, url),
113
+ user_data_id: this.getQueryParamValue(USER_DATA_ID_QUERY_PARAM, url),
114
+ user_data_read_only: this.getQueryParamValue(USER_DATA_READ_ONLY_QUERY_PARAM, url)?.toLowerCase() === 'true',
115
+ });
116
+ }
117
+
118
+ private getQueryParamValue(param: string, url: URL) {
119
+ return url.searchParams.has(param) ? url.searchParams.get(param) : undefined;
120
+ }
121
+
122
+ }
@@ -0,0 +1,18 @@
1
+ import { Controller, Get } from '@nestjs/common';
2
+ import { BrowserPoolService } from 'src/services/browser-pool.service';
3
+
4
+ @Controller('/status')
5
+ export class PoolStatusController {
6
+
7
+ constructor(private readonly pool_service: BrowserPoolService) { }
8
+
9
+ @Get()
10
+ getStatus() {
11
+ return {
12
+ sigterm_received: this.pool_service.sigterm_received,
13
+ browsers_running: this.pool_service.nb_browser_instances_alive,
14
+ max_browsers: this.pool_service.max_browser_instances,
15
+ };
16
+ }
17
+
18
+ }
package/src/main.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { NestFactory } from '@nestjs/core';
2
+ import { AppModule } from './app.module';
3
+
4
+ async function bootstrap() {
5
+ const app = await NestFactory.create(AppModule);
6
+ await app.listen(process.env.PORT || 9999);
7
+ }
8
+
9
+ bootstrap();
@@ -0,0 +1,139 @@
1
+ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
2
+ import { BrowserInstance } from 'src/components/browser-instance.component';
3
+ import * as EventEmitter from 'events';
4
+ import { ModuleRef } from '@nestjs/core';
5
+
6
+ type PoolServiceEvents = {
7
+ browser_instance_created: [BrowserInstance];
8
+ }
9
+
10
+ export interface BrowserPoolStatus {
11
+ id: string;
12
+ started_at: string;
13
+ max_browser_instances: number;
14
+ tags: { [key: string]: string; };
15
+ }
16
+
17
+ @Injectable()
18
+ export class BrowserPoolService extends EventEmitter<PoolServiceEvents> implements OnModuleInit {
19
+
20
+ private readonly logger = new Logger(BrowserPoolService.name);
21
+
22
+ readonly #id: string = crypto.randomUUID();
23
+ readonly #started_at = new Date().toISOString();
24
+ readonly #tags: { [key: string]: string; } = {};
25
+
26
+ readonly max_browser_instances = parseInt(process.env.MAX_BROWSER_INSTANCES);
27
+
28
+ readonly #browser_instances = new Map<string, BrowserInstance>();
29
+
30
+ #sigterm_received: boolean = false;
31
+
32
+ constructor(
33
+ private readonly module_ref: ModuleRef,
34
+ ) {
35
+ super();
36
+
37
+ (process.env.TAGS || '').split(',').forEach(tag => {
38
+ const [key, value] = tag.split('=');
39
+
40
+ this.#tags[key] = value;
41
+ })
42
+ }
43
+
44
+ get status(): BrowserPoolStatus {
45
+ return {
46
+ id: this.#id,
47
+ started_at: this.#started_at,
48
+ max_browser_instances: this.max_browser_instances,
49
+ tags: this.#tags
50
+ };
51
+ }
52
+
53
+ get id() {
54
+ return this.#id;
55
+ }
56
+
57
+ get started_at() {
58
+ return this.#started_at;
59
+ }
60
+
61
+ get tags() {
62
+ return this.#tags;
63
+ }
64
+
65
+ get sigterm_received() {
66
+ return this.#sigterm_received;
67
+ }
68
+
69
+ get nb_browser_instances_alive() {
70
+ return this.#browser_instances.size;
71
+ }
72
+
73
+ get browser_instances() {
74
+ return [...this.#browser_instances.values()]
75
+ }
76
+
77
+ async onModuleInit() {
78
+ process.on('SIGTERM', () => {
79
+ this.logger.log('SIGTERM received');
80
+ this.shutdown();
81
+ });
82
+ }
83
+
84
+ getBrowserInstanceById(id: string): BrowserInstance | undefined {
85
+ return this.#browser_instances.get(id);
86
+ }
87
+
88
+ createBrowserInstance(id: string = crypto.randomUUID()) {
89
+ if (this.sigterm_received) {
90
+ this.logger.log(`Can't create new browser instance. Sigterm has been received.`);
91
+ return;
92
+ }
93
+
94
+ const browser_instance = new BrowserInstance(id, this.module_ref);
95
+
96
+ this.logger.log(`Created browser instance ${browser_instance.id}.`);
97
+
98
+ this.emit('browser_instance_created', browser_instance);
99
+
100
+ browser_instance.on('terminated', () => {
101
+ this.logger.log(`Browser instance ${browser_instance.id} terminated. Removing from pool.`);
102
+ this.#browser_instances.delete(browser_instance.id);
103
+ });
104
+
105
+ this.#browser_instances.set(browser_instance.id, browser_instance);
106
+
107
+ return browser_instance;
108
+ }
109
+
110
+ async shutdown() {
111
+ if (this.#sigterm_received) {
112
+ return;
113
+ }
114
+
115
+ this.#sigterm_received = true;
116
+
117
+ this.logger.log('Shutdown requested.');
118
+
119
+ for (const browser_instance of this.#browser_instances.values()) {
120
+ if (browser_instance.in_use) {
121
+ continue;
122
+ }
123
+
124
+ await browser_instance.close();
125
+ }
126
+
127
+ setInterval(async () => {
128
+ if (this.#browser_instances.size !== 0) {
129
+ this.logger.log(`Waiting for ${this.#browser_instances.size} browser instance(s) to close.`);
130
+ return;
131
+ }
132
+
133
+ this.logger.log(`All browser instances are closed.`);
134
+
135
+ process.exit(0);
136
+ }, 200);
137
+ }
138
+
139
+ }
@@ -0,0 +1,42 @@
1
+ import { Injectable, Logger } from "@nestjs/common";
2
+ import axios from "axios";
3
+
4
+ @Injectable()
5
+ export class TimezoneService {
6
+
7
+ private readonly logger = new Logger(TimezoneService.name);
8
+
9
+ #default_timezone: Promise<string>;
10
+
11
+ getDefaultTimezone() {
12
+ if (!this.#default_timezone) {
13
+ this.#default_timezone = new Promise(async (res) => {
14
+ res((await (await fetch('http://ip-api.com/json?fields=timezone')).json()).timezone);
15
+ });
16
+ }
17
+
18
+ return this.#default_timezone;
19
+ }
20
+
21
+ async getProxyTimezone(proxy_url: string): Promise<string> {
22
+ try {
23
+ const url = new URL(proxy_url);
24
+ const response = await axios.get('http://ip-api.com/json?fields=timezone', {
25
+ proxy: {
26
+ protocol: url.protocol,
27
+ host: url.hostname,
28
+ port: parseInt(url.port),
29
+ auth: {
30
+ username: url.username,
31
+ password: url.password,
32
+ }
33
+ }
34
+ });
35
+
36
+ return response.data.timezone;
37
+ } catch (e) {
38
+ this.logger.error(`Error while getting timezone.`, e);
39
+ }
40
+ }
41
+
42
+ }
@@ -0,0 +1,111 @@
1
+ import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
2
+ import { Injectable, Logger } from "@nestjs/common";
3
+ import * as fs from 'fs';
4
+ import * as fsPromise from 'fs/promises';
5
+ import * as zlib from 'zlib';
6
+ import * as tar from 'tar';
7
+ import LimitStream from "src/transforms/limit-stream";
8
+
9
+ export abstract class UserDataService {
10
+
11
+ abstract downloadUserData(id: string, user_data_folder: string): Promise<void>;
12
+ abstract uploadUserData(id: string, user_data_folder: string): Promise<void>;
13
+
14
+ /**
15
+ *
16
+ * @param user_data_folder The user data folder to tar
17
+ * @returns The tar file path
18
+ */
19
+ protected async tarUserDataFolder(user_data_folder: string): Promise<string> {
20
+ const tar_file = `${crypto.randomUUID()}.tar.gz`;
21
+
22
+ await this.#cleanUserDataFolder(user_data_folder);
23
+
24
+ await tar.create({
25
+ gzip: true,
26
+ file: tar_file,
27
+ cwd: user_data_folder
28
+ }, ['.']);
29
+
30
+ return tar_file;
31
+ }
32
+
33
+ protected async untarUserDataFolder(tar_file: string, user_data_folder: string) {
34
+ await new Promise((res, rej) => {
35
+ fs.createReadStream(tar_file)
36
+ .pipe(zlib.createGunzip())
37
+ .pipe(new LimitStream(104857600)) // Max 100 MB decompression
38
+ .pipe(tar.extract({ cwd: user_data_folder }))
39
+ .on('finish', () => {
40
+ res(undefined);
41
+ })
42
+ .on('error', (err) => {
43
+ rej(err);
44
+ });
45
+ });
46
+ await this.#cleanUserDataFolder(user_data_folder);
47
+ }
48
+
49
+ /**
50
+ * Remove the undesired files(locks, logs, metrics) and folders from the user data
51
+ * @param user_data_folder
52
+ */
53
+ async #cleanUserDataFolder(user_data_folder: string) {
54
+ await Promise.allSettled([
55
+ fsPromise.rm(`${user_data_folder}/Default/Sessions`, { recursive: true, force: true }),
56
+ fsPromise.rm(`${user_data_folder}/SingletonLock`, { force: true }),
57
+ fsPromise.rm(`${user_data_folder}/SingletonCookie`, { force: true }),
58
+ fsPromise.rm(`${user_data_folder}/SingletonSocket`, { force: true }),
59
+ ]);
60
+ }
61
+
62
+ }
63
+
64
+ @Injectable()
65
+ export class UserDataS3Service extends UserDataService {
66
+
67
+ private readonly logger = new Logger(UserDataS3Service.name);
68
+
69
+ constructor(
70
+ private readonly s3_client: S3Client,
71
+ ) {
72
+ super();
73
+ }
74
+
75
+ async downloadUserData(id: string, user_data_folder: string) {
76
+ try {
77
+ const tar_file = `/tmp/${crypto.randomUUID()}`;
78
+ const response = await this.s3_client.send(new GetObjectCommand({
79
+ Bucket: process.env.S3_USER_DATA_BUCKET,
80
+ Key: id,
81
+ }));
82
+
83
+ await fsPromise.writeFile(tar_file, await response.Body.transformToByteArray());
84
+ await this.untarUserDataFolder(tar_file, user_data_folder);
85
+ await fsPromise.rm(tar_file);
86
+
87
+ this.logger.log(`Downloaded user data ${id} folder:${user_data_folder}`);
88
+ } catch (e) {
89
+ if (e.Code === 'NoSuchKey') {
90
+ return;
91
+ }
92
+
93
+ throw e;
94
+ }
95
+ }
96
+
97
+ async uploadUserData(id: string, user_data_folder: string) {
98
+ const tar_file = await this.tarUserDataFolder(user_data_folder);
99
+
100
+ await this.s3_client.send(new PutObjectCommand({
101
+ Bucket: process.env.S3_USER_DATA_BUCKET,
102
+ Key: id,
103
+ Body: await fsPromise.readFile(tar_file)
104
+ }));
105
+
106
+ await fsPromise.rm(tar_file);
107
+
108
+ this.logger.log(`Uploaded user data ${id} folder:${user_data_folder}`);
109
+ }
110
+
111
+ }
@@ -0,0 +1,30 @@
1
+ import { Transform, TransformCallback, TransformOptions } from 'stream';
2
+
3
+ /**
4
+ * A Transform stream that enforces a maximum size limit on the data passing through.
5
+ * @param {number} limitInBytes - The maximum allowed uncompressed size.
6
+ */
7
+ export default class LimitStream extends Transform {
8
+
9
+ private bytes_processed = 0;
10
+
11
+ constructor(
12
+ private readonly byte_limit: number,
13
+ options?: TransformOptions
14
+ ) {
15
+ super(options);
16
+ }
17
+
18
+ _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback) {
19
+ this.bytes_processed += chunk.length;
20
+
21
+ if (this.bytes_processed > this.byte_limit) {
22
+ const error = new Error(`Decompression limit exceeded. Max size: ${this.byte_limit} bytes`);
23
+ this.destroy(error);
24
+ return callback(error);
25
+ }
26
+
27
+ this.push(chunk);
28
+ callback();
29
+ }
30
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "declaration": true,
5
+ "removeComments": true,
6
+ "emitDecoratorMetadata": true,
7
+ "experimentalDecorators": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "target": "ES2021",
10
+ "sourceMap": true,
11
+ "outDir": "./dist",
12
+ "baseUrl": "./",
13
+ "incremental": true,
14
+ "skipLibCheck": true,
15
+ "strictNullChecks": false,
16
+ "noImplicitAny": false,
17
+ "strictBindCallApply": false,
18
+ "forceConsistentCasingInFileNames": false,
19
+ "noFallthroughCasesInSwitch": false
20
+ }
21
+ }