@boarteam/boar-pack-common-backend 2.1.3
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/package.json +28 -0
- package/src/index.ts +5 -0
- package/src/modules/cluster/cluster.config.ts +38 -0
- package/src/modules/cluster/cluster.interface.ts +25 -0
- package/src/modules/cluster/cluster.module.ts +47 -0
- package/src/modules/cluster/cluster.service.ts +178 -0
- package/src/modules/cluster/index.ts +4 -0
- package/src/modules/scrypt/scrypt.config.ts +36 -0
- package/src/modules/scrypt/scrypt.module.ts +20 -0
- package/src/modules/scrypt/scrypt.service.ts +41 -0
- package/src/modules/websockets/dto/websockets.dto.ts +11 -0
- package/src/modules/websockets/index.ts +2 -0
- package/src/modules/websockets/websockets.clients.ts +163 -0
- package/src/modules/websockets/websockets.exception-filter.ts +18 -0
- package/src/modules/websockets/websockets.module.ts +13 -0
- package/src/tools/index.ts +4 -0
- package/src/tools/named-logger.ts +14 -0
- package/src/tools/select-query-builder.extension.ts +28 -0
- package/src/tools/typeorm.execption-filter.ts +56 -0
- package/src/tools/virtual-column.decorator.ts +13 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boarteam/boar-pack-common-backend",
|
|
3
|
+
"version": "2.1.3",
|
|
4
|
+
"description": "NestJS Users module including permissions system, authentication strategies etc",
|
|
5
|
+
"repository": "git@github.com:boarteam/boar-pack.git",
|
|
6
|
+
"author": "Andrew Balakirev <balakirev.andrey@gmail.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"main": "src/index",
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"registry": "https://registry.npmjs.org/",
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@nestjs/common": "^9.0.0",
|
|
18
|
+
"@nestjs/core": "^9.0.0",
|
|
19
|
+
"typeorm": "^0.2.25"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"yalc:push": "yalc push"
|
|
23
|
+
},
|
|
24
|
+
"gitHead": "392553082a2f9f0124d05eaa11d0cdb6079766e1",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@nestjs/websockets": "^10.3.9"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import cluster from "node:cluster";
|
|
4
|
+
|
|
5
|
+
export type TClusterConfig = {
|
|
6
|
+
port: number;
|
|
7
|
+
worker?: string;
|
|
8
|
+
disableCluster?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class ClusterConfigService {
|
|
13
|
+
constructor(private configService: ConfigService) {
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get config(): TClusterConfig {
|
|
17
|
+
const port = Number.parseInt(this.configService.get<string>('PORT') || '');
|
|
18
|
+
const worker = this.configService.get<string>('WORKER');
|
|
19
|
+
const disableCluster = this.configService.get<string>('DISABLE_CLUSTER') === 'true';
|
|
20
|
+
|
|
21
|
+
if (!disableCluster) {
|
|
22
|
+
if (!port) {
|
|
23
|
+
throw new Error('PORT env variable is not set');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (cluster.isWorker && !worker) {
|
|
27
|
+
throw new Error('WORKER env variable is not set');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
port,
|
|
34
|
+
worker,
|
|
35
|
+
disableCluster,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Cluster } from "node:cluster";
|
|
2
|
+
import { Worker } from "cluster";
|
|
3
|
+
|
|
4
|
+
export interface WorkerSettings {
|
|
5
|
+
workerId: string;
|
|
6
|
+
workerName?: string;
|
|
7
|
+
portIncrement: number | null;
|
|
8
|
+
port?: number | null;
|
|
9
|
+
extraEnv?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ClusterSettings {
|
|
13
|
+
clusterId: string;
|
|
14
|
+
appRole: string;
|
|
15
|
+
restartDelay?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ClusterInterface {
|
|
19
|
+
getSettings(): Promise<ClusterSettings>;
|
|
20
|
+
getWorkersSettings(): Promise<WorkerSettings[]>;
|
|
21
|
+
onWorkerRun?(worker: WorkerSettings, vars: Record<string, string>): void;
|
|
22
|
+
onWorkerExit?(worker: WorkerSettings, code: number, signal: string): void;
|
|
23
|
+
onWorkerListening?(worker: WorkerSettings): void;
|
|
24
|
+
onClusterMessage?(cluster: Cluster, worker: Worker, message: any): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Logger, Module, OnApplicationBootstrap } from '@nestjs/common';
|
|
2
|
+
import { ClusterService } from "./cluster.service";
|
|
3
|
+
import { nextTick } from "process";
|
|
4
|
+
import { ConfigModule } from "@nestjs/config";
|
|
5
|
+
import { ClusterConfigService, TClusterConfig } from "./cluster.config";
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [
|
|
9
|
+
ConfigModule,
|
|
10
|
+
],
|
|
11
|
+
providers: [
|
|
12
|
+
ClusterConfigService,
|
|
13
|
+
ClusterService,
|
|
14
|
+
],
|
|
15
|
+
exports: [
|
|
16
|
+
ClusterService,
|
|
17
|
+
ClusterConfigService,
|
|
18
|
+
],
|
|
19
|
+
})
|
|
20
|
+
export class ClusterModule implements OnApplicationBootstrap {
|
|
21
|
+
private readonly logger = new Logger(ClusterModule.name);
|
|
22
|
+
private config: TClusterConfig;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly clusterService: ClusterService,
|
|
26
|
+
private readonly configService: ClusterConfigService,
|
|
27
|
+
) {
|
|
28
|
+
this.config = configService.config;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async onApplicationBootstrap() {
|
|
32
|
+
if (this.config.disableCluster) {
|
|
33
|
+
this.logger.debug('Cluster is disabled');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// We need to wait for event emitter to be initialized else broadcast will not work
|
|
38
|
+
nextTick(async () => {
|
|
39
|
+
try {
|
|
40
|
+
await this.clusterService.runClusters();
|
|
41
|
+
} catch (e) {
|
|
42
|
+
this.logger.error('Error while running clusters');
|
|
43
|
+
this.logger.error(e, e.stack);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
|
2
|
+
import { ClusterInterface, ClusterSettings, WorkerSettings } from "./cluster.interface";
|
|
3
|
+
import cluster from "node:cluster";
|
|
4
|
+
import { ClusterConfigService, TClusterConfig } from "./cluster.config";
|
|
5
|
+
import { Worker } from "cluster";
|
|
6
|
+
|
|
7
|
+
type TWorkerVars = {
|
|
8
|
+
APP_ROLE: string;
|
|
9
|
+
WORKER: string;
|
|
10
|
+
WORKER_NAME: string;
|
|
11
|
+
PORT?: string;
|
|
12
|
+
} & {
|
|
13
|
+
[key: string]: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Injectable()
|
|
17
|
+
export class ClusterService {
|
|
18
|
+
private readonly logger = new Logger(ClusterService.name);
|
|
19
|
+
private readonly STOP_WORKER = 'SIGKILL';
|
|
20
|
+
private readonly config: TClusterConfig;
|
|
21
|
+
|
|
22
|
+
private workersByClusters: Map<ClusterInterface, Map<Worker['id'], {
|
|
23
|
+
worker: Worker,
|
|
24
|
+
workerVars: TWorkerVars,
|
|
25
|
+
}>> = new Map();
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly configService: ClusterConfigService,
|
|
29
|
+
) {
|
|
30
|
+
this.config = this.configService.config;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public addCluster(cluster: ClusterInterface) {
|
|
34
|
+
this.workersByClusters.set(cluster, new Map());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public async runClusters(): Promise<void> {
|
|
38
|
+
const clusters = Array.from(this.workersByClusters.keys());
|
|
39
|
+
await Promise.allSettled(clusters.map(cluster => this.runCluster(cluster)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async runCluster(runningCluster: ClusterInterface) {
|
|
43
|
+
const clusterSettings = await runningCluster.getSettings();
|
|
44
|
+
const workersSettings = await runningCluster.getWorkersSettings();
|
|
45
|
+
workersSettings.forEach(settings => this.runWorker({
|
|
46
|
+
runningCluster,
|
|
47
|
+
clusterSettings,
|
|
48
|
+
workerSettings: settings
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private runWorker({
|
|
53
|
+
runningCluster,
|
|
54
|
+
clusterSettings,
|
|
55
|
+
workerSettings,
|
|
56
|
+
callback,
|
|
57
|
+
}: {
|
|
58
|
+
runningCluster: ClusterInterface,
|
|
59
|
+
clusterSettings: ClusterSettings,
|
|
60
|
+
workerSettings: WorkerSettings,
|
|
61
|
+
callback?: () => void,
|
|
62
|
+
}) {
|
|
63
|
+
const clusterWorkers = this.workersByClusters.get(runningCluster);
|
|
64
|
+
|
|
65
|
+
if (!clusterWorkers) {
|
|
66
|
+
throw new Error(`Cluster ${runningCluster.constructor.name} is not found`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const workerName = workerSettings.workerName || workerSettings.workerId;
|
|
70
|
+
this.logger.log(`Starting worker ${workerName}...`);
|
|
71
|
+
|
|
72
|
+
const vars: TWorkerVars = {
|
|
73
|
+
APP_ROLE: clusterSettings.appRole,
|
|
74
|
+
WORKER: workerSettings.workerId,
|
|
75
|
+
WORKER_NAME: workerName,
|
|
76
|
+
...workerSettings.extraEnv,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (workerSettings.port) {
|
|
80
|
+
vars.PORT = String(workerSettings.port);
|
|
81
|
+
} else if (workerSettings.portIncrement !== null) {
|
|
82
|
+
vars.PORT = String(this.config.port + workerSettings.portIncrement);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
runningCluster.onWorkerRun?.(workerSettings, vars);
|
|
86
|
+
|
|
87
|
+
const workerProcess = cluster.fork(vars);
|
|
88
|
+
clusterWorkers.set(workerProcess.id, {
|
|
89
|
+
worker: workerProcess,
|
|
90
|
+
workerVars: vars,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
cluster.on('message', (worker, message) => {
|
|
94
|
+
runningCluster.onClusterMessage?.(cluster, worker, message);
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
workerProcess.on('exit', (code, signal) => {
|
|
98
|
+
clusterWorkers.delete(workerProcess.id);
|
|
99
|
+
runningCluster.onWorkerExit?.(workerSettings, code, signal);
|
|
100
|
+
|
|
101
|
+
this.logger.error(`Worker ${workerName} died with code ${code} and signal ${signal}`);
|
|
102
|
+
|
|
103
|
+
if (signal === this.STOP_WORKER) {
|
|
104
|
+
this.logger.log(`Worker ${workerName} was stopped and will not be restarted`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (typeof clusterSettings.restartDelay === 'undefined') {
|
|
109
|
+
this.logger.log(`Restarting worker ${workerName} without delay...`);
|
|
110
|
+
this.startWorker(runningCluster, workerSettings.workerId).catch(err => {
|
|
111
|
+
this.logger.error(`Failed to restart worker ${workerName}: ${err}`);
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
this.logger.log(`Worker ${workerName} will be restarted in ${clusterSettings.restartDelay}ms...`);
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
this.logger.log(`Restarting worker ${workerName}...`);
|
|
117
|
+
this.startWorker(runningCluster, workerSettings.workerId).catch(err => {
|
|
118
|
+
this.logger.error(`Failed to restart worker ${workerName}: ${err}`);
|
|
119
|
+
});
|
|
120
|
+
}, clusterSettings.restartDelay);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
workerProcess.once('listening', () => {
|
|
125
|
+
this.logger.log(`Worker ${workerName} started`);
|
|
126
|
+
callback?.();
|
|
127
|
+
runningCluster.onWorkerListening?.(workerSettings);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public async setWorkerState(cluster: ClusterInterface, workerId: string, state: boolean) {
|
|
132
|
+
const workers = this.workersByClusters.get(cluster);
|
|
133
|
+
if (!workers) {
|
|
134
|
+
throw new NotFoundException(`Cluster ${cluster.constructor.name} is not found`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const workerDescriptor = Array.from(workers.values()).find(worker => worker.workerVars.WORKER === workerId);
|
|
138
|
+
if (!workerDescriptor) {
|
|
139
|
+
if (state) {
|
|
140
|
+
await this.startWorker(cluster, workerId);
|
|
141
|
+
} else {
|
|
142
|
+
this.logger.log(`Worker ${workerId} is not found and should be stopped - doing nothing`);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
if (state) {
|
|
146
|
+
await this.updateWorker(workerDescriptor.worker, workerId);
|
|
147
|
+
} else {
|
|
148
|
+
await this.stopWorker(workerDescriptor.worker, workerId);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async startWorker(runningCluster: ClusterInterface, workerId: string) {
|
|
154
|
+
const clusterSettings = await runningCluster.getSettings();
|
|
155
|
+
const workersSettings = await runningCluster.getWorkersSettings();
|
|
156
|
+
const workerSettings = workersSettings.find(settings => settings.workerId === workerId);
|
|
157
|
+
|
|
158
|
+
if (!workerSettings) {
|
|
159
|
+
throw new NotFoundException(`Settings for worker ${workerId} are not found`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.runWorker({
|
|
163
|
+
runningCluster,
|
|
164
|
+
clusterSettings,
|
|
165
|
+
workerSettings,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async stopWorker(worker: Worker, workerId: string) {
|
|
170
|
+
this.logger.log(`Stopping worker ${workerId}...`);
|
|
171
|
+
worker.kill(this.STOP_WORKER);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async updateWorker(worker: Worker, workerId: string) {
|
|
175
|
+
this.logger.log(`Updating worker ${workerId} by restarting...`);
|
|
176
|
+
worker.kill();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import Joi from "joi";
|
|
4
|
+
|
|
5
|
+
export type TScryptConfig = {
|
|
6
|
+
salt: string;
|
|
7
|
+
iv: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
@Injectable()
|
|
11
|
+
export class ScryptConfigService {
|
|
12
|
+
private ivSchema = Joi.string().length(16).hex();
|
|
13
|
+
|
|
14
|
+
constructor(private configService: ConfigService) {
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get config(): TScryptConfig {
|
|
18
|
+
const salt = this.configService.get<string>('SCRYPT_SALT');
|
|
19
|
+
const iv = this.configService.get<string>('SCRYPT_IV');
|
|
20
|
+
|
|
21
|
+
if (!salt || !iv) {
|
|
22
|
+
throw new Error('SCRYPT_SALT or SCRYPT_IV env variables are not set');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { error } = this.ivSchema.validate(iv);
|
|
26
|
+
if (error) {
|
|
27
|
+
console.error('IV is invalid:', error.details[0].message);
|
|
28
|
+
throw new Error('IV is invalid, check SCRYPT_IV env variable');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
salt,
|
|
33
|
+
iv,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import ScryptService from "./scrypt.service";
|
|
3
|
+
import { ScryptConfigService } from "./scrypt.config";
|
|
4
|
+
import { ConfigModule } from "@nestjs/config";
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [
|
|
8
|
+
ConfigModule,
|
|
9
|
+
],
|
|
10
|
+
controllers: [],
|
|
11
|
+
providers: [
|
|
12
|
+
ScryptService,
|
|
13
|
+
ScryptConfigService,
|
|
14
|
+
],
|
|
15
|
+
exports: [
|
|
16
|
+
ScryptService,
|
|
17
|
+
],
|
|
18
|
+
})
|
|
19
|
+
export class ScryptModule {
|
|
20
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common";
|
|
2
|
+
import { ScryptConfigService, TScryptConfig } from "./scrypt.config";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
import { createCipheriv, scrypt } from "crypto";
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class ScryptService {
|
|
8
|
+
private config: TScryptConfig;
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
private scryptConfig: ScryptConfigService,
|
|
12
|
+
) {
|
|
13
|
+
this.config = this.scryptConfig.config;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async encrypt(str: string): Promise<string> {
|
|
17
|
+
const key = (await promisify(scrypt)(this.config.salt, 'salt', 32)) as Buffer;
|
|
18
|
+
const cipher = createCipheriv('aes-256-ctr', key, this.config.iv);
|
|
19
|
+
|
|
20
|
+
const encrypted = Buffer.concat([
|
|
21
|
+
cipher.update(str),
|
|
22
|
+
cipher.final(),
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
return encrypted.toString('base64');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async decrypt(str: string): Promise<string> {
|
|
29
|
+
const key = (await promisify(scrypt)(this.config.salt, 'salt', 32)) as Buffer;
|
|
30
|
+
const decipher = createCipheriv('aes-256-ctr', key, this.config.iv);
|
|
31
|
+
|
|
32
|
+
const decrypted = Buffer.concat([
|
|
33
|
+
decipher.update(str, 'base64'),
|
|
34
|
+
decipher.final(),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
return decrypted.toString();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default ScryptService;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Injectable, Logger, Scope } from "@nestjs/common";
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
import { isSafeNumber, parse } from "lossless-json";
|
|
4
|
+
|
|
5
|
+
export type TBaseConfig<EventType> = {
|
|
6
|
+
url: string;
|
|
7
|
+
onOpen?: () => void;
|
|
8
|
+
onMessage?: (event: EventType) => void;
|
|
9
|
+
onClose?: () => void;
|
|
10
|
+
getEventError?: (event: EventType) => string | null | undefined;
|
|
11
|
+
reconnectTimeout?: number;
|
|
12
|
+
ignoreInvalidJson?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export enum WsErrorCodes {
|
|
16
|
+
ConnectionClosed = 1000,
|
|
17
|
+
InvalidJson = 4000,
|
|
18
|
+
ErrorMessage = 4001,
|
|
19
|
+
Unauthorized = 4003,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Injectable({ scope: Scope.TRANSIENT })
|
|
23
|
+
export class WebsocketsClients<IncomeEventType,
|
|
24
|
+
OutgoingEventType = any,
|
|
25
|
+
ConfigType extends TBaseConfig<IncomeEventType> = TBaseConfig<IncomeEventType>,
|
|
26
|
+
> {
|
|
27
|
+
private readonly logger = new Logger(WebsocketsClients.name);
|
|
28
|
+
private readonly clients = new WeakMap<WebSocket, ConfigType>();
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private toSafeNumberOrString(value: string): number | string {
|
|
34
|
+
return isSafeNumber(value) ? parseFloat(value) : value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public connect(config: ConfigType): WebSocket {
|
|
38
|
+
this.logger.log(`Connecting to ${config.url}...`);
|
|
39
|
+
const ws = new WebSocket(config.url);
|
|
40
|
+
|
|
41
|
+
this.clients.set(ws, config);
|
|
42
|
+
|
|
43
|
+
ws.on('open', async () => {
|
|
44
|
+
this.logger.log(`Connected to ${config.url}`);
|
|
45
|
+
try {
|
|
46
|
+
await config.onOpen?.();
|
|
47
|
+
} catch (e) {
|
|
48
|
+
this.logger.error(`Error, while calling onOpen for ${config.url} socket`);
|
|
49
|
+
this.logger.error(e);
|
|
50
|
+
ws.close(WsErrorCodes.ErrorMessage, e.toString());
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
ws.on('error', (error) => {
|
|
55
|
+
this.logger.error(`Error for ${config.url} socket:`);
|
|
56
|
+
this.logger.error(error);
|
|
57
|
+
ws.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
ws.on('close', (code, reason) => {
|
|
61
|
+
this.onClose(ws, config, code, reason);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
ws.on('message', (msg: Buffer) => {
|
|
65
|
+
let event: IncomeEventType;
|
|
66
|
+
try {
|
|
67
|
+
this.logger.verbose(msg);
|
|
68
|
+
event = parse(String(msg), null, this.toSafeNumberOrString) as IncomeEventType;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
this.logger.error(`Error, while parsing message from WS server ${msg}`);
|
|
71
|
+
this.logger.error(e, e.stack);
|
|
72
|
+
if (!config.ignoreInvalidJson) {
|
|
73
|
+
ws?.close(WsErrorCodes.InvalidJson, "Invalid JSON");
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const errorMessage = config.getEventError?.(event);
|
|
79
|
+
if (errorMessage) {
|
|
80
|
+
this.logger.error(`Error from WS server: ${errorMessage}`);
|
|
81
|
+
ws.close(WsErrorCodes.ErrorMessage, errorMessage);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
config.onMessage?.(event);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return ws;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private onClose(ws: WebSocket, config: ConfigType | undefined, code: number, reason: Buffer) {
|
|
92
|
+
this.logger.log(`${config?.url} socket closed with code ${code} and reason: ${reason.toString()}`);
|
|
93
|
+
ws.removeAllListeners();
|
|
94
|
+
config?.onClose?.();
|
|
95
|
+
this.clients.delete(ws);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public close(client: WebSocket): Promise<void> {
|
|
99
|
+
const config = this.clients.get(client);
|
|
100
|
+
if (typeof config?.reconnectTimeout === "number") {
|
|
101
|
+
clearTimeout(config.reconnectTimeout);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (client.readyState === WebSocket.CLOSED) {
|
|
105
|
+
this.onClose(client, config, WsErrorCodes.ConnectionClosed, Buffer.from("Closed by client"));
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
client.once('close', () => resolve());
|
|
111
|
+
client.close();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public reconnect(client: WebSocket, timeout: number) {
|
|
116
|
+
const config = this.clients.get(client);
|
|
117
|
+
if (!config) {
|
|
118
|
+
throw new Error(`Can't reconnect, config not found`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
config.reconnectTimeout = setTimeout(() => {
|
|
122
|
+
this.connect(config);
|
|
123
|
+
}, timeout) as unknown as number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public send<T>(client: WebSocket, data: T): Promise<void> {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const send = () => {
|
|
129
|
+
client.send(JSON.stringify(data), (err) => {
|
|
130
|
+
if (err) {
|
|
131
|
+
this.logger.error(`Error sending data to WS server`);
|
|
132
|
+
this.logger.error(err);
|
|
133
|
+
reject(err);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
resolve();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (client.readyState !== WebSocket.OPEN) {
|
|
142
|
+
const timeout = setTimeout(() => {
|
|
143
|
+
reject(new Error(`Can't send data to WS server, because client is not connected`));
|
|
144
|
+
}, 1000);
|
|
145
|
+
|
|
146
|
+
client.once('open', () => {
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
send();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
send();
|
|
156
|
+
} catch (e) {
|
|
157
|
+
this.logger.error(`Error, while sending data to WS server`);
|
|
158
|
+
this.logger.error(e);
|
|
159
|
+
reject(e);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Catch, ArgumentsHost } from '@nestjs/common';
|
|
2
|
+
import { BaseWsExceptionFilter } from '@nestjs/websockets';
|
|
3
|
+
import { WebsocketsErrorEventDto } from "./dto/websockets.dto";
|
|
4
|
+
|
|
5
|
+
@Catch()
|
|
6
|
+
export class WebsocketsExceptionFilter extends BaseWsExceptionFilter {
|
|
7
|
+
catch(exception: Error, host: ArgumentsHost) {
|
|
8
|
+
const client = host.switchToWs().getClient();
|
|
9
|
+
client.send(JSON.stringify({
|
|
10
|
+
event: 'error',
|
|
11
|
+
data: {
|
|
12
|
+
message: exception.message,
|
|
13
|
+
},
|
|
14
|
+
} as WebsocketsErrorEventDto));
|
|
15
|
+
|
|
16
|
+
super.catch(exception, host);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ConsoleLogger } from "@nestjs/common";
|
|
2
|
+
|
|
3
|
+
export class NamedLogger extends ConsoleLogger {
|
|
4
|
+
private readonly prefix: string;
|
|
5
|
+
|
|
6
|
+
constructor(prefix?: string) {
|
|
7
|
+
super();
|
|
8
|
+
this.prefix = prefix || 'Nest';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
formatPid(pid: number): string {
|
|
12
|
+
return `[${this.prefix}] ${pid} - `;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { SelectQueryBuilder } from 'typeorm';
|
|
2
|
+
import { VIRTUAL_COLUMN_KEY } from './virtual-column.decorator';
|
|
3
|
+
|
|
4
|
+
declare module 'typeorm' {
|
|
5
|
+
interface SelectQueryBuilder<Entity> {
|
|
6
|
+
// @ts-ignore
|
|
7
|
+
executeEntitiesAndRawResults(): Promise<{ entities: Entity[]; raw: any[] }>;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// @ts-ignore
|
|
12
|
+
const originExecute = SelectQueryBuilder.prototype.executeEntitiesAndRawResults;
|
|
13
|
+
// @ts-ignore
|
|
14
|
+
SelectQueryBuilder.prototype.executeEntitiesAndRawResults = async function (
|
|
15
|
+
queryRunner,
|
|
16
|
+
) {
|
|
17
|
+
// @ts-ignore
|
|
18
|
+
const { entities, raw } = await originExecute.call(this, queryRunner);
|
|
19
|
+
entities.forEach((entity, index) => {
|
|
20
|
+
const metaInfo = Reflect.getMetadata(VIRTUAL_COLUMN_KEY, entity) ?? {};
|
|
21
|
+
const item = raw[index];
|
|
22
|
+
|
|
23
|
+
for (const [propertyKey, name] of Object.entries<string>(metaInfo)) {
|
|
24
|
+
entity[propertyKey] = item[name];
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return { entities, raw };
|
|
28
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
|
2
|
+
import { QueryFailedError } from "typeorm";
|
|
3
|
+
import { BaseExceptionFilter } from "@nestjs/core";
|
|
4
|
+
|
|
5
|
+
enum ErrorCode {
|
|
6
|
+
// pg
|
|
7
|
+
UniqueViolation = '23505',
|
|
8
|
+
|
|
9
|
+
// mysql
|
|
10
|
+
FailedRemovingByForeignKey = 'ER_ROW_IS_REFERENCED',
|
|
11
|
+
FailedRemovingByForeignKey2 = 'ER_ROW_IS_REFERENCED_2',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Catch(QueryFailedError)
|
|
15
|
+
export class TypeOrmExceptionFilter extends BaseExceptionFilter implements ExceptionFilter {
|
|
16
|
+
private static uniqueConstraintMessages: Record<string, string> = {}
|
|
17
|
+
|
|
18
|
+
public static setUniqueConstraintMessage(constraint: string, message: string) {
|
|
19
|
+
this.uniqueConstraintMessages[constraint] = message;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private readonly logger = new Logger(TypeOrmExceptionFilter.name);
|
|
23
|
+
|
|
24
|
+
catch(exception: QueryFailedError, host: ArgumentsHost) {
|
|
25
|
+
const ctx = host.switchToHttp();
|
|
26
|
+
const response = ctx.getResponse();
|
|
27
|
+
const { code, constraint }: { code: string, constraint: string } = exception as any || {};
|
|
28
|
+
|
|
29
|
+
this.logger.error(`QueryFailedError: ${exception.message}, code: ${code}, constraint: ${constraint}`);
|
|
30
|
+
|
|
31
|
+
switch (code) {
|
|
32
|
+
case ErrorCode.UniqueViolation:
|
|
33
|
+
response
|
|
34
|
+
.status(400)
|
|
35
|
+
.json({
|
|
36
|
+
statusCode: 400,
|
|
37
|
+
message: TypeOrmExceptionFilter.uniqueConstraintMessages[constraint] || 'The record already exists',
|
|
38
|
+
error: 'Bad Request',
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
|
|
42
|
+
case ErrorCode.FailedRemovingByForeignKey:
|
|
43
|
+
case ErrorCode.FailedRemovingByForeignKey2:
|
|
44
|
+
response
|
|
45
|
+
.status(400)
|
|
46
|
+
.json({
|
|
47
|
+
statusCode: 400,
|
|
48
|
+
message: 'System cannot remove the record, please remove all related records first',
|
|
49
|
+
error: 'Bad Request',
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return super.catch(exception, host);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
|
|
3
|
+
export const VIRTUAL_COLUMN_KEY = Symbol('VIRTUAL_COLUMN_KEY');
|
|
4
|
+
|
|
5
|
+
export function VirtualColumn(name?: string): PropertyDecorator {
|
|
6
|
+
return (target, propertyKey) => {
|
|
7
|
+
const metaInfo = Reflect.getMetadata(VIRTUAL_COLUMN_KEY, target) || {};
|
|
8
|
+
|
|
9
|
+
metaInfo[propertyKey] = name ?? propertyKey;
|
|
10
|
+
|
|
11
|
+
Reflect.defineMetadata(VIRTUAL_COLUMN_KEY, metaInfo, target);
|
|
12
|
+
};
|
|
13
|
+
}
|