@orion-js/echoes 3.12.0 → 3.13.1

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/lib/config.d.ts +3 -0
  3. package/lib/config.js +4 -0
  4. package/lib/echo/deserialize.d.ts +1 -0
  5. package/lib/echo/deserialize.js +11 -0
  6. package/lib/echo/index.d.ts +9 -0
  7. package/lib/echo/index.js +29 -0
  8. package/lib/echo/types.d.ts +5 -0
  9. package/lib/echo/types.js +6 -0
  10. package/lib/index.d.ts +7 -0
  11. package/lib/index.js +43 -0
  12. package/lib/publish/index.d.ts +5 -0
  13. package/lib/publish/index.js +29 -0
  14. package/lib/publish/serialize.d.ts +1 -0
  15. package/lib/publish/serialize.js +13 -0
  16. package/lib/request/getPassword.d.ts +1 -0
  17. package/lib/request/getPassword.js +16 -0
  18. package/lib/request/getSignature.d.ts +1 -0
  19. package/lib/request/getSignature.js +15 -0
  20. package/lib/request/getURL.d.ts +1 -0
  21. package/lib/request/getURL.js +16 -0
  22. package/lib/request/index.d.ts +2 -0
  23. package/lib/request/index.js +57 -0
  24. package/lib/request/makeRequest.d.ts +2 -0
  25. package/lib/request/makeRequest.js +26 -0
  26. package/lib/requestsHandler/checkSignature.d.ts +1 -0
  27. package/lib/requestsHandler/checkSignature.js +13 -0
  28. package/lib/requestsHandler/getEcho.d.ts +1 -0
  29. package/lib/requestsHandler/getEcho.js +18 -0
  30. package/lib/requestsHandler/index.d.ts +3 -0
  31. package/lib/requestsHandler/index.js +44 -0
  32. package/lib/service/index.d.ts +8 -0
  33. package/lib/service/index.js +54 -0
  34. package/lib/service/index.test.d.ts +1 -0
  35. package/lib/service/index.test.js +51 -0
  36. package/lib/startService/KafkaManager.d.ts +24 -0
  37. package/lib/startService/KafkaManager.js +146 -0
  38. package/lib/startService/index.d.ts +3 -0
  39. package/lib/startService/index.js +33 -0
  40. package/lib/tests/requests/index.test.d.ts +1 -0
  41. package/lib/tests/requests/index.test.js +99 -0
  42. package/lib/types.d.ts +98 -0
  43. package/lib/types.js +2 -0
  44. package/package.json +19 -25
  45. package/dist/index.cjs +0 -74298
  46. package/dist/index.d.ts +0 -124
  47. package/dist/index.js +0 -74262
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const kafkajs_1 = require("kafkajs");
7
+ const types_1 = __importDefault(require("../echo/types"));
8
+ const HEARTBEAT_INTERVAL_SECONDS = 5; // This value must be less than the kafkajs session timeout
9
+ const CHECK_JOIN_CONSUMER_INTERVAL_SECONDS = 30;
10
+ const DEFAULT_PARTITIONS_CONSUMED_CONCURRENTLY = 4; // How many partitions to consume concurrently, adjust this with the members to partitions ratio to avoid idle consumers.
11
+ const DEFAULT_MEMBERS_TO_PARTITIONS_RATIO = 1; // How many members are in comparison to partitions, this is used to determine if the consumer group has room for more members. Numbers over 1 leads to idle consumers. Numbers under 1 needs partitionsConsumedConcurrently to be more than 1.
12
+ /**
13
+ * Manages the Kafka connection and the consumers.
14
+ */
15
+ class KafkaManager {
16
+ constructor(options) {
17
+ this.kafka = new kafkajs_1.Kafka(options.client);
18
+ this.options = options;
19
+ this.producer = this.kafka.producer(options.producer);
20
+ this.consumer = this.kafka.consumer(options.consumer);
21
+ this.topics = Object.keys(options.echoes).filter(key => options.echoes[key].type === types_1.default.event);
22
+ }
23
+ async checkJoinConsumerGroupConditions() {
24
+ const admin = this.kafka.admin();
25
+ try {
26
+ await admin.connect();
27
+ const groupDescriptions = await admin.describeGroups([this.options.consumer.groupId]);
28
+ const group = groupDescriptions.groups[0];
29
+ if (group.state === 'Empty') {
30
+ console.info(`Echoes: Consumer group ${this.options.consumer.groupId} is empty, joining`);
31
+ return true;
32
+ }
33
+ const topicsMetadata = await admin.fetchTopicMetadata({ topics: this.topics });
34
+ const totalPartitions = topicsMetadata.topics.reduce((acc, topic) => acc + topic.partitions.length, 0);
35
+ console.info(`Echoes: Consumer group ${this.options.consumer.groupId} has ${group.members.length} members and ${totalPartitions} partitions`);
36
+ const partitionsRatio = this.options.membersToPartitionsRatio || DEFAULT_MEMBERS_TO_PARTITIONS_RATIO;
37
+ const partitionsThreshold = Math.ceil(totalPartitions * partitionsRatio);
38
+ if (partitionsThreshold > group.members.length) {
39
+ console.info(`Echoes: Consumer group ${this.options.consumer.groupId} has room for more members ${group.members.length}/${partitionsThreshold}, joining`);
40
+ return true;
41
+ }
42
+ }
43
+ catch (error) {
44
+ console.error(`Echoes: Error checking consumer group conditions, join: ${error.message}`);
45
+ return true;
46
+ }
47
+ finally {
48
+ await admin.disconnect().catch(error => {
49
+ console.error(`Echoes: Error disconnecting admin client: ${error.message}`);
50
+ });
51
+ }
52
+ }
53
+ async joinConsumerGroup() {
54
+ await this.consumer.connect();
55
+ await this.consumer.subscribe({ topics: this.topics });
56
+ await this.consumer.run({
57
+ partitionsConsumedConcurrently: this.options.partitionsConsumedConcurrently || DEFAULT_PARTITIONS_CONSUMED_CONCURRENTLY,
58
+ eachMessage: params => this.handleMessage(params),
59
+ });
60
+ }
61
+ async conditionalStart() {
62
+ if (await this.checkJoinConsumerGroupConditions()) {
63
+ await this.joinConsumerGroup();
64
+ return true;
65
+ }
66
+ }
67
+ async start() {
68
+ if (this.started)
69
+ return;
70
+ await this.producer.connect();
71
+ this.started = await this.conditionalStart();
72
+ if (this.started)
73
+ return;
74
+ console.info('Echoes: Delaying consumer group join, waiting for conditions to be met');
75
+ this.interval = setInterval(async () => {
76
+ this.started = await this.conditionalStart();
77
+ if (this.started)
78
+ clearInterval(this.interval);
79
+ }, CHECK_JOIN_CONSUMER_INTERVAL_SECONDS * 1000);
80
+ }
81
+ async stop() {
82
+ console.warn('Echoes: Stopping echoes');
83
+ if (this.interval)
84
+ clearInterval(this.interval);
85
+ if (this.consumer)
86
+ await this.consumer.disconnect();
87
+ if (this.producer)
88
+ await this.producer.disconnect();
89
+ }
90
+ async handleMessage(params) {
91
+ const echo = this.options.echoes[params.topic];
92
+ if (!echo || echo.type !== types_1.default.event) {
93
+ console.warn(`Echoes: Received a message for an unknown topic: ${params.topic}, ignoring it`);
94
+ return;
95
+ }
96
+ let intervalsCount = 0;
97
+ const heartbeatInterval = setInterval(async () => {
98
+ await params.heartbeat().catch(error => {
99
+ console.warn(`Echoes: Error sending heartbeat: ${error.message}`);
100
+ });
101
+ intervalsCount++;
102
+ if ((intervalsCount * HEARTBEAT_INTERVAL_SECONDS) % 30 === 0) {
103
+ console.warn(`Echoes: Event is taking too long to process: ${params.topic} ${intervalsCount * HEARTBEAT_INTERVAL_SECONDS}s`);
104
+ }
105
+ }, HEARTBEAT_INTERVAL_SECONDS * 1000);
106
+ try {
107
+ await echo.onMessage(params).catch(error => this.handleRetries(echo, params, error));
108
+ }
109
+ catch (error) {
110
+ console.error(`Echoes: error processing a message: ${params.topic} ${error.message}`);
111
+ throw error;
112
+ }
113
+ finally {
114
+ clearInterval(heartbeatInterval);
115
+ }
116
+ }
117
+ async handleRetries(echo, params, error) {
118
+ const { message, topic } = params;
119
+ const retries = Number.parseInt(message?.headers?.retries?.toString() || '0', 10);
120
+ if (echo.attemptsBeforeDeadLetter === undefined || echo.attemptsBeforeDeadLetter === null) {
121
+ throw error;
122
+ }
123
+ const maxRetries = echo.attemptsBeforeDeadLetter || 0;
124
+ const exceededMaxRetries = retries >= maxRetries;
125
+ const nextTopic = exceededMaxRetries ? `DLQ-${topic}` : topic;
126
+ await this.producer.send({
127
+ topic: nextTopic,
128
+ messages: [
129
+ {
130
+ value: message.value.toString(),
131
+ headers: {
132
+ retries: String(retries + 1),
133
+ error: error.message,
134
+ },
135
+ },
136
+ ],
137
+ });
138
+ if (exceededMaxRetries) {
139
+ console.error(`Echoes: a message has reached the maximum number of retries, sending it to DLQ: ${nextTopic}`);
140
+ }
141
+ else {
142
+ console.warn(`Echoes: a retryable message failed "${error.message}", re-sending it to topic: ${nextTopic}`);
143
+ }
144
+ }
145
+ }
146
+ exports.default = KafkaManager;
@@ -0,0 +1,3 @@
1
+ import { EchoesOptions } from '../types';
2
+ export default function startService(options: EchoesOptions): Promise<void>;
3
+ export declare function stopService(): Promise<void>;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.stopService = void 0;
7
+ const config_1 = __importDefault(require("../config"));
8
+ const requestsHandler_1 = __importDefault(require("../requestsHandler"));
9
+ const KafkaManager_1 = __importDefault(require("./KafkaManager"));
10
+ const http_1 = require("@orion-js/http");
11
+ let kafkaManager = null;
12
+ async function startService(options) {
13
+ config_1.default.echoes = options.echoes;
14
+ if (options.requests) {
15
+ config_1.default.requests = options.requests;
16
+ (0, http_1.registerRoute)((0, requestsHandler_1.default)(options));
17
+ }
18
+ if (options.client) {
19
+ kafkaManager = new KafkaManager_1.default(options);
20
+ await kafkaManager.start();
21
+ config_1.default.producer = kafkaManager.producer;
22
+ config_1.default.consumer = kafkaManager.consumer;
23
+ }
24
+ }
25
+ exports.default = startService;
26
+ async function stopService() {
27
+ if (kafkaManager) {
28
+ console.info('Stoping echoes...');
29
+ await kafkaManager.stop();
30
+ console.info('Echoes stopped');
31
+ }
32
+ }
33
+ exports.stopService = stopService;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const __1 = require("../..");
7
+ const supertest_1 = __importDefault(require("supertest"));
8
+ const http_1 = require("@orion-js/http");
9
+ const schema_1 = require("@orion-js/schema");
10
+ describe('Test echoes requests', () => {
11
+ const makeRequest = async (options) => {
12
+ const app = (0, http_1.getApp)();
13
+ const response = await (0, supertest_1.default)(app).post('/echoes-services').send(options.data);
14
+ return {
15
+ statusCode: response.statusCode,
16
+ data: response.body,
17
+ };
18
+ };
19
+ it('Should start echoes requests service', () => {
20
+ const echoes = {
21
+ test: (0, __1.echo)({
22
+ type: 'request',
23
+ async resolve() { },
24
+ }),
25
+ };
26
+ (0, __1.startService)({
27
+ echoes,
28
+ requests: {
29
+ key: 'secret',
30
+ services: {},
31
+ },
32
+ });
33
+ });
34
+ it('Should be able to make a echoes request passing dates as params', async () => {
35
+ expect.assertions(5);
36
+ const echoes = {
37
+ test: (0, __1.echo)({
38
+ type: 'request',
39
+ async resolve(params) {
40
+ expect(params.hello).toBe('world');
41
+ expect(params.date).toBeInstanceOf(Date);
42
+ return {
43
+ text: 'Hello world',
44
+ date: params.date,
45
+ };
46
+ },
47
+ }),
48
+ };
49
+ (0, __1.startService)({
50
+ echoes,
51
+ requests: {
52
+ key: 'secret',
53
+ services: { test: 'mockURL' },
54
+ makeRequest,
55
+ },
56
+ });
57
+ const date = new Date();
58
+ const result = await (0, __1.request)({
59
+ method: 'test',
60
+ service: 'test',
61
+ params: {
62
+ hello: 'world',
63
+ date,
64
+ },
65
+ });
66
+ expect(result.text).toBe('Hello world');
67
+ expect(result.date).toBeInstanceOf(Date);
68
+ expect(result.date.getTime()).toBe(date.getTime());
69
+ });
70
+ it('should pass errors to Orion errors', async () => {
71
+ const echoes = {
72
+ test: (0, __1.echo)({
73
+ type: 'request',
74
+ async resolve() {
75
+ throw new schema_1.ValidationError({ hello: 'world' });
76
+ },
77
+ }),
78
+ };
79
+ (0, __1.startService)({
80
+ echoes,
81
+ requests: {
82
+ key: 'secret',
83
+ services: { test: 'mockURL' },
84
+ makeRequest,
85
+ },
86
+ });
87
+ expect.assertions(1);
88
+ try {
89
+ await (0, __1.request)({
90
+ method: 'test',
91
+ service: 'test',
92
+ params: {},
93
+ });
94
+ }
95
+ catch (error) {
96
+ expect(error.validationErrors).toEqual({ hello: 'world' });
97
+ }
98
+ });
99
+ });
package/lib/types.d.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { ConsumerConfig, KafkaConfig, ProducerConfig, Consumer, Producer, EachMessagePayload } from 'kafkajs';
2
+ export interface EchoConfig {
3
+ type: 'event' | 'request';
4
+ resolve(params: any, context?: any): Promise<any>;
5
+ attemptsBeforeDeadLetter?: number;
6
+ }
7
+ export interface EchoType extends EchoConfig {
8
+ onMessage(messageData: EachMessagePayload): Promise<void>;
9
+ onRequest(serializedParams: string): any;
10
+ }
11
+ export interface PublishOptions<TParams = any> {
12
+ topic: string;
13
+ params: TParams;
14
+ acks?: number;
15
+ timeout?: number;
16
+ }
17
+ export interface RequestOptions<TParams> {
18
+ method: string;
19
+ service: string;
20
+ params: TParams;
21
+ retries?: number;
22
+ timeout?: number;
23
+ }
24
+ export interface RequestHandlerResponse {
25
+ result?: any;
26
+ error?: any;
27
+ isUserError?: boolean;
28
+ isValidationError?: boolean;
29
+ errorInfo?: {
30
+ error: string;
31
+ message: string;
32
+ extra?: any;
33
+ validationErrors?: any;
34
+ };
35
+ }
36
+ export interface MakeRequestParams {
37
+ url: string;
38
+ retries?: number;
39
+ timeout?: number;
40
+ data: {
41
+ body: object;
42
+ signature: string;
43
+ };
44
+ }
45
+ export interface RequestMakerResult {
46
+ statusCode: number;
47
+ data: object;
48
+ }
49
+ export type RequestMaker = (options: MakeRequestParams) => Promise<RequestMakerResult>;
50
+ export interface RequestsConfig {
51
+ /**
52
+ * The secret key used to sign all requests. Shared between all your services.
53
+ * You can also set the env var echoes_password or process.env.ECHOES_PASSWORD
54
+ */
55
+ key?: string;
56
+ /**
57
+ * The path of the echoes http receiver. Defaults to /echoes-services
58
+ */
59
+ handlerPath?: string;
60
+ /**
61
+ * Map of all the services that have echoes requests handlers
62
+ */
63
+ services?: {
64
+ [key: string]: string;
65
+ };
66
+ /**
67
+ * A custom function that make the requests to the services. Uses axios by default
68
+ */
69
+ makeRequest?: RequestMaker;
70
+ }
71
+ export interface EchoesMap {
72
+ [key: string]: EchoType;
73
+ }
74
+ export interface EchoesOptions {
75
+ client?: KafkaConfig;
76
+ producer?: ProducerConfig;
77
+ consumer?: ConsumerConfig;
78
+ requests?: RequestsConfig;
79
+ echoes: EchoesMap;
80
+ /**
81
+ * Defaults to true. When true, allows a reconnecting service to read missed messages.
82
+ */
83
+ readTopicsFromBeginning?: boolean;
84
+ /**
85
+ * Defaults to 4. How many partitions to consume concurrently, adjust this with the members to partitions ratio to avoid idle consumers.
86
+ */
87
+ partitionsConsumedConcurrently?: number;
88
+ /**
89
+ * Defaults to 1. How many members are in comparison to partitions, this is used to determine if the consumer group has room for more members. Numbers over 1 leads to idle consumers. Numbers under 1 needs partitionsConsumedConcurrently to be more than 1.
90
+ */
91
+ membersToPartitionsRatio?: number;
92
+ }
93
+ export interface EchoesConfigHandler {
94
+ producer?: Producer;
95
+ consumer?: Consumer;
96
+ requests?: RequestsConfig;
97
+ echoes?: EchoesMap;
98
+ }
package/lib/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,28 +1,27 @@
1
1
  {
2
2
  "name": "@orion-js/echoes",
3
- "version": "3.12.0",
4
- "main": "./dist/index.cjs",
5
- "types": "./dist/index.d.ts",
3
+ "version": "3.13.1",
4
+ "main": "lib/index.js",
5
+ "types": "lib/index.d.ts",
6
6
  "files": [
7
- "dist"
7
+ "/lib"
8
8
  ],
9
9
  "author": "nicolaslopezj",
10
10
  "license": "MIT",
11
11
  "scripts": {
12
- "test": "bun test",
12
+ "test": "jest",
13
13
  "prepare": "yarn run build",
14
- "clean": "rm -rf ./dist",
15
- "build": "bun run build.ts",
14
+ "clean": "rm -rf ./lib",
15
+ "build": "yarn run clean && tsc",
16
16
  "watch": "tsc -w",
17
- "upgrade-interactive": "yarn upgrade-interactive",
18
- "dev": "bun --watch src/index.ts"
17
+ "upgrade-interactive": "yarn upgrade-interactive"
19
18
  },
20
19
  "dependencies": {
21
- "@orion-js/env": "^4.0.0-alpha.2",
22
- "@orion-js/helpers": "^4.0.0-alpha.2",
23
- "@orion-js/http": "^4.0.0-alpha.2",
24
- "@orion-js/schema": "^4.0.0-alpha.2",
25
- "@orion-js/services": "^4.0.0-alpha.2",
20
+ "@orion-js/env": "^3.13.1",
21
+ "@orion-js/helpers": "^3.13.1",
22
+ "@orion-js/http": "^3.13.1",
23
+ "@orion-js/schema": "^3.13.1",
24
+ "@orion-js/services": "^3.13.1",
26
25
  "axios": "^0.24.0",
27
26
  "jssha": "^3.2.0",
28
27
  "kafkajs": "^2.2.4",
@@ -30,21 +29,16 @@
30
29
  "serialize-javascript": "^6.0.0"
31
30
  },
32
31
  "devDependencies": {
32
+ "@types/jest": "27.0.2",
33
33
  "@types/supertest": "2.0.11",
34
+ "jest": "27.3.1",
34
35
  "reflect-metadata": "^0.1.13",
35
36
  "supertest": "^6.1.6",
36
- "typescript": "^5.4.5",
37
- "@types/bun": "^1.2.4"
37
+ "ts-jest": "27.0.7",
38
+ "typescript": "^4.4.4"
38
39
  },
39
40
  "publishConfig": {
40
41
  "access": "public"
41
42
  },
42
- "gitHead": "9fd28b6f6b348cebc9f0dc207805647969277372",
43
- "type": "module",
44
- "module": "./dist/index.js",
45
- "exports": {
46
- "types": "./dist/index.d.ts",
47
- "import": "./dist/index.js",
48
- "require": "./dist/index.cjs"
49
- }
50
- }
43
+ "gitHead": "18ab5a9711863e6bd3d3c61e7bb8f9067afca82c"
44
+ }