blockmine 1.25.0 → 1.27.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 (165) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/backend/cli.js +1 -1
  3. package/backend/package.json +2 -2
  4. package/backend/prisma/migrations/20260328173000_add_plugin_source_ref/migration.sql +2 -0
  5. package/backend/prisma/migrations/migration_lock.toml +2 -2
  6. package/backend/prisma/schema.prisma +2 -0
  7. package/backend/src/api/routes/apiKeys.js +8 -0
  8. package/backend/src/api/routes/bots.js +258 -9
  9. package/backend/src/api/routes/eventGraphs.js +151 -1
  10. package/backend/src/api/routes/health.js +38 -0
  11. package/backend/src/api/routes/nodeRegistry.js +63 -0
  12. package/backend/src/api/routes/plugins.js +254 -29
  13. package/backend/src/container.js +11 -8
  14. package/backend/src/core/BotCommandLoader.js +161 -0
  15. package/backend/src/core/BotConnection.js +125 -0
  16. package/backend/src/core/BotEventHandlers.js +234 -0
  17. package/backend/src/core/BotIPCHandler.js +445 -0
  18. package/backend/src/core/BotManager.js +15 -7
  19. package/backend/src/core/BotProcess.js +75 -142
  20. package/backend/src/core/EventGraphManager.js +7 -3
  21. package/backend/src/core/GraphDebugHandler.js +229 -0
  22. package/backend/src/core/GraphDebugIPC.js +117 -0
  23. package/backend/src/core/GraphExecutionEngine.js +545 -978
  24. package/backend/src/core/GraphTraversal.js +80 -0
  25. package/backend/src/core/GraphValidation.js +73 -0
  26. package/backend/src/core/NodeDefinition.js +138 -0
  27. package/backend/src/core/NodeRegistry.js +153 -141
  28. package/backend/src/core/PluginManager.js +272 -31
  29. package/backend/src/core/RewindSignal.js +9 -0
  30. package/backend/src/core/config/ConfigValidator.js +72 -0
  31. package/backend/src/core/config/FeatureFlags.js +52 -0
  32. package/backend/src/core/config/__tests__/ConfigValidator.test.js +232 -0
  33. package/backend/src/core/domain/entities/Bot.js +39 -0
  34. package/backend/src/core/domain/entities/Command.js +41 -0
  35. package/backend/src/core/domain/entities/EventGraph.js +39 -0
  36. package/backend/src/core/domain/entities/Plugin.js +45 -0
  37. package/backend/src/core/domain/entities/User.js +40 -0
  38. package/backend/src/core/domain/services/DependencyResolver.js +168 -0
  39. package/backend/src/core/domain/services/GraphValidator.js +117 -0
  40. package/backend/src/core/domain/services/PermissionChecker.js +34 -0
  41. package/backend/src/core/domain/services/__tests__/DependencyResolver.test.js +126 -0
  42. package/backend/src/core/domain/valueObjects/BotConfig.js +27 -0
  43. package/backend/src/core/domain/valueObjects/DependencyGraph.js +86 -0
  44. package/backend/src/core/domain/valueObjects/PluginManifest.js +36 -0
  45. package/backend/src/core/errors/BaseError.js +29 -0
  46. package/backend/src/core/errors/ErrorHandler.js +81 -0
  47. package/backend/src/core/errors/__tests__/ErrorHandler.test.js +188 -0
  48. package/backend/src/core/errors/index.js +68 -0
  49. package/backend/src/core/infrastructure/BatchingUtility.js +66 -0
  50. package/backend/src/core/infrastructure/CircuitBreaker.js +103 -0
  51. package/backend/src/core/infrastructure/ConnectionPool.js +81 -0
  52. package/backend/src/core/infrastructure/RateLimiter.js +64 -0
  53. package/backend/src/core/infrastructure/__tests__/BatchingUtility.test.js +86 -0
  54. package/backend/src/core/infrastructure/__tests__/CircuitBreaker.test.js +156 -0
  55. package/backend/src/core/infrastructure/__tests__/ConnectionPool.test.js +146 -0
  56. package/backend/src/core/infrastructure/__tests__/RateLimiter.test.js +171 -0
  57. package/backend/src/core/ipc/botApiFactory.js +72 -0
  58. package/backend/src/core/ipc/ipcMessageTypes.js +115 -0
  59. package/backend/src/core/logging/AuditLogger.js +61 -0
  60. package/backend/src/core/logging/StructuredLogger.js +80 -0
  61. package/backend/src/core/logging/__tests__/StructuredLogger.test.js +213 -0
  62. package/backend/src/core/logging/index.js +7 -0
  63. package/backend/src/core/metrics/MetricsCollector.js +104 -0
  64. package/backend/src/core/metrics/__tests__/MetricsCollector.test.js +131 -0
  65. package/backend/src/core/node-registries/actionsNodes.js +191 -0
  66. package/backend/src/core/node-registries/arraysNodes.js +152 -0
  67. package/backend/src/core/node-registries/botNodes.js +48 -0
  68. package/backend/src/core/node-registries/containerNodes.js +141 -0
  69. package/backend/src/core/node-registries/dataNodes.js +284 -0
  70. package/backend/src/core/node-registries/debugNodes.js +23 -0
  71. package/backend/src/core/node-registries/eventsNodes.js +223 -0
  72. package/backend/src/core/node-registries/flowNodes.js +151 -0
  73. package/backend/src/core/node-registries/furnaceNodes.js +123 -0
  74. package/backend/src/core/node-registries/index.js +108 -0
  75. package/backend/src/core/node-registries/inventory.js +102 -106
  76. package/backend/src/core/node-registries/logicNodes.js +54 -0
  77. package/backend/src/core/node-registries/mathNodes.js +38 -0
  78. package/backend/src/core/node-registries/navigationNodes.js +109 -0
  79. package/backend/src/core/node-registries/objectsNodes.js +90 -0
  80. package/backend/src/core/node-registries/stringsNodes.js +165 -0
  81. package/backend/src/core/node-registries/timeNodes.js +105 -0
  82. package/backend/src/core/node-registries/typeNodes.js +22 -0
  83. package/backend/src/core/node-registries/usersNodes.js +126 -0
  84. package/backend/src/core/nodes/arrays/shuffle.js +14 -0
  85. package/backend/src/core/nodes/bot/get_name.js +8 -0
  86. package/backend/src/core/nodes/bot/stop_bot.js +5 -0
  87. package/backend/src/core/nodes/container/open.js +101 -111
  88. package/backend/src/core/nodes/data/store_read.js +26 -0
  89. package/backend/src/core/nodes/data/store_write.js +23 -0
  90. package/backend/src/core/nodes/event/call_event.js +31 -0
  91. package/backend/src/core/nodes/event/custom_event.js +8 -0
  92. package/backend/src/core/nodes/flow/timer.js +35 -0
  93. package/backend/src/core/nodes/inventory/drop.js +73 -65
  94. package/backend/src/core/nodes/inventory/equip.js +54 -45
  95. package/backend/src/core/nodes/inventory/select_slot.js +48 -46
  96. package/backend/src/core/nodes/navigation/follow.js +54 -51
  97. package/backend/src/core/nodes/navigation/go_to.js +41 -53
  98. package/backend/src/core/nodes/navigation/go_to_entity.js +65 -69
  99. package/backend/src/core/nodes/navigation/go_to_player.js +65 -70
  100. package/backend/src/core/nodes/navigation/stop.js +17 -26
  101. package/backend/src/core/nodes/users/add_to_group.js +24 -0
  102. package/backend/src/core/nodes/users/check_permission.js +26 -0
  103. package/backend/src/core/nodes/users/remove_from_group.js +24 -0
  104. package/backend/src/core/services/BotIPCMessageRouter.js +337 -0
  105. package/backend/src/core/services/BotLifecycleService.js +41 -632
  106. package/backend/src/core/services/CacheManager.js +83 -23
  107. package/backend/src/core/services/CrashRestartManager.js +42 -0
  108. package/backend/src/core/services/DebugSessionManager.js +114 -12
  109. package/backend/src/core/services/EventGraphService.js +69 -0
  110. package/backend/src/core/services/MinecraftBotManager.js +9 -1
  111. package/backend/src/core/services/PluginManagementService.js +84 -0
  112. package/backend/src/core/services/TestModeContext.js +65 -0
  113. package/backend/src/core/services/__tests__/CacheManager.test.js +168 -0
  114. package/backend/src/core/services.js +1 -11
  115. package/backend/src/core/validation/InputValidator.js +167 -0
  116. package/backend/src/core/validation/__tests__/InputValidator.test.js +296 -0
  117. package/backend/src/real-time/botApi/index.js +1 -1
  118. package/backend/src/real-time/socketHandler.js +26 -0
  119. package/backend/src/server.js +10 -5
  120. package/frontend/dist/assets/{browser-ponyfill-DN7pwmHT.js → browser-ponyfill-D8y0Ty7C.js} +1 -1
  121. package/frontend/dist/assets/index-CFJLS0dk.css +32 -0
  122. package/frontend/dist/assets/{index-LSy71uwm.js → index-D91UGNMG.js} +1880 -1881
  123. package/frontend/dist/index.html +2 -2
  124. package/frontend/dist/locales/en/bots.json +4 -1
  125. package/frontend/dist/locales/en/common.json +7 -1
  126. package/frontend/dist/locales/en/login.json +2 -0
  127. package/frontend/dist/locales/en/management.json +79 -1
  128. package/frontend/dist/locales/en/nodes.json +59 -4
  129. package/frontend/dist/locales/en/plugin-detail.json +24 -4
  130. package/frontend/dist/locales/en/plugins.json +226 -7
  131. package/frontend/dist/locales/en/setup.json +2 -0
  132. package/frontend/dist/locales/en/sidebar.json +171 -3
  133. package/frontend/dist/locales/en/visual-editor.json +230 -31
  134. package/frontend/dist/locales/ru/bots.json +4 -1
  135. package/frontend/dist/locales/ru/login.json +2 -0
  136. package/frontend/dist/locales/ru/management.json +79 -1
  137. package/frontend/dist/locales/ru/minecraft-viewer.json +3 -0
  138. package/frontend/dist/locales/ru/nodes.json +105 -51
  139. package/frontend/dist/locales/ru/plugins.json +103 -4
  140. package/frontend/dist/locales/ru/setup.json +2 -0
  141. package/frontend/dist/locales/ru/sidebar.json +171 -3
  142. package/frontend/dist/locales/ru/visual-editor.json +232 -33
  143. package/frontend/package.json +2 -0
  144. package/nul +12 -0
  145. package/package.json +3 -3
  146. package/scripts/postinstall.js +38 -0
  147. package/backend/package-lock.json +0 -6801
  148. package/backend/src/core/node-registries/actions.js +0 -202
  149. package/backend/src/core/node-registries/arrays.js +0 -155
  150. package/backend/src/core/node-registries/bot.js +0 -23
  151. package/backend/src/core/node-registries/container.js +0 -162
  152. package/backend/src/core/node-registries/data.js +0 -290
  153. package/backend/src/core/node-registries/debug.js +0 -26
  154. package/backend/src/core/node-registries/events.js +0 -201
  155. package/backend/src/core/node-registries/flow.js +0 -139
  156. package/backend/src/core/node-registries/furnace.js +0 -143
  157. package/backend/src/core/node-registries/logic.js +0 -62
  158. package/backend/src/core/node-registries/math.js +0 -42
  159. package/backend/src/core/node-registries/navigation.js +0 -111
  160. package/backend/src/core/node-registries/objects.js +0 -98
  161. package/backend/src/core/node-registries/strings.js +0 -187
  162. package/backend/src/core/node-registries/time.js +0 -113
  163. package/backend/src/core/node-registries/type.js +0 -25
  164. package/backend/src/core/node-registries/users.js +0 -79
  165. package/frontend/dist/assets/index-SfhKxI4-.css +0 -32
@@ -0,0 +1,188 @@
1
+ const ErrorHandler = require('../ErrorHandler');
2
+ const { BaseError, ValidationError, NotFoundError } = require('../index');
3
+
4
+ function makeLogger() {
5
+ return {
6
+ warn: jest.fn(),
7
+ error: jest.fn(),
8
+ };
9
+ }
10
+
11
+ describe('ErrorHandler', () => {
12
+ describe('isOperational', () => {
13
+ it('returns true for BaseError with isOperational=true', () => {
14
+ const err = new ValidationError('errors.validation');
15
+ expect(ErrorHandler.isOperational(err)).toBe(true);
16
+ });
17
+
18
+ it('returns false for BaseError with isOperational=false', () => {
19
+ const err = new BaseError('errors.critical', { isOperational: false });
20
+ expect(ErrorHandler.isOperational(err)).toBe(false);
21
+ });
22
+
23
+ it('returns false for plain Error', () => {
24
+ expect(ErrorHandler.isOperational(new Error('boom'))).toBe(false);
25
+ });
26
+
27
+ it('returns false for non-error values', () => {
28
+ expect(ErrorHandler.isOperational(null)).toBe(false);
29
+ expect(ErrorHandler.isOperational('string')).toBe(false);
30
+ expect(ErrorHandler.isOperational(42)).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe('sanitize', () => {
35
+ it('redacts password field', () => {
36
+ const result = ErrorHandler.sanitize({ password: 'secret123' });
37
+ expect(result.password).toBe('[REDACTED]');
38
+ });
39
+
40
+ it('redacts token field', () => {
41
+ const result = ErrorHandler.sanitize({ token: 'abc' });
42
+ expect(result.token).toBe('[REDACTED]');
43
+ });
44
+
45
+ it('redacts secret field', () => {
46
+ const result = ErrorHandler.sanitize({ secret: 'xyz' });
47
+ expect(result.secret).toBe('[REDACTED]');
48
+ });
49
+
50
+ it('redacts key field', () => {
51
+ const result = ErrorHandler.sanitize({ key: 'mykey' });
52
+ expect(result.key).toBe('[REDACTED]');
53
+ });
54
+
55
+ it('redacts authorization field', () => {
56
+ const result = ErrorHandler.sanitize({ authorization: 'Bearer token' });
57
+ expect(result.authorization).toBe('[REDACTED]');
58
+ });
59
+
60
+ it('redacts cookie field', () => {
61
+ const result = ErrorHandler.sanitize({ cookie: 'session=abc' });
62
+ expect(result.cookie).toBe('[REDACTED]');
63
+ });
64
+
65
+ it('redacts apiKey field', () => {
66
+ const result = ErrorHandler.sanitize({ apiKey: 'key123' });
67
+ expect(result.apiKey).toBe('[REDACTED]');
68
+ });
69
+
70
+ it('redacts api_key field', () => {
71
+ const result = ErrorHandler.sanitize({ api_key: 'key123' });
72
+ expect(result.api_key).toBe('[REDACTED]');
73
+ });
74
+
75
+ it('preserves non-sensitive fields', () => {
76
+ const result = ErrorHandler.sanitize({ botId: 'bot1', userId: 'user1' });
77
+ expect(result.botId).toBe('bot1');
78
+ expect(result.userId).toBe('user1');
79
+ });
80
+
81
+ it('redacts nested sensitive fields', () => {
82
+ const result = ErrorHandler.sanitize({ user: { password: 'secret', name: 'Alice' } });
83
+ expect(result.user.password).toBe('[REDACTED]');
84
+ expect(result.user.name).toBe('Alice');
85
+ });
86
+
87
+ it('handles arrays', () => {
88
+ const result = ErrorHandler.sanitize([{ password: 'x' }, { name: 'y' }]);
89
+ expect(result[0].password).toBe('[REDACTED]');
90
+ expect(result[1].name).toBe('y');
91
+ });
92
+
93
+ it('returns primitives unchanged', () => {
94
+ expect(ErrorHandler.sanitize('hello')).toBe('hello');
95
+ expect(ErrorHandler.sanitize(42)).toBe(42);
96
+ expect(ErrorHandler.sanitize(null)).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe('handle', () => {
101
+ it('logs operational errors with warn', () => {
102
+ const logger = makeLogger();
103
+ const handler = new ErrorHandler({ logger });
104
+ const err = new ValidationError('errors.validation');
105
+
106
+ handler.handle(err, { botId: 'b1', userId: 'u1' });
107
+
108
+ expect(logger.warn).toHaveBeenCalledTimes(1);
109
+ expect(logger.error).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('logs non-operational errors with error', () => {
113
+ const logger = makeLogger();
114
+ const handler = new ErrorHandler({ logger });
115
+ const err = new Error('unexpected');
116
+
117
+ handler.handle(err, {});
118
+
119
+ expect(logger.error).toHaveBeenCalledTimes(1);
120
+ expect(logger.warn).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('returns structured error response', () => {
124
+ const logger = makeLogger();
125
+ const handler = new ErrorHandler({ logger });
126
+ const err = new NotFoundError('errors.notFound');
127
+
128
+ const result = handler.handle(err);
129
+
130
+ expect(result.success).toBe(false);
131
+ expect(result.error.code).toBe('NOT_FOUND');
132
+ expect(result.error.statusCode).toBe(404);
133
+ expect(result.error.messageKey).toBe('errors.notFound');
134
+ expect(result.error.isOperational).toBe(true);
135
+ });
136
+
137
+ it('sanitizes sensitive data in context before logging', () => {
138
+ const logger = makeLogger();
139
+ const handler = new ErrorHandler({ logger });
140
+ const err = new ValidationError('errors.validation');
141
+
142
+ handler.handle(err, { password: 'secret', botId: 'b1' });
143
+
144
+ const loggedEntry = logger.warn.mock.calls[0][0];
145
+ expect(loggedEntry.context.password).toBe('[REDACTED]');
146
+ expect(loggedEntry.context.botId).toBe('b1');
147
+ });
148
+
149
+ it('emits error:handled event', () => {
150
+ const logger = makeLogger();
151
+ const handler = new ErrorHandler({ logger });
152
+ const err = new ValidationError('errors.validation');
153
+ const listener = jest.fn();
154
+
155
+ handler.on('error:handled', listener);
156
+ handler.handle(err, { botId: 'b1' });
157
+
158
+ expect(listener).toHaveBeenCalledTimes(1);
159
+ const payload = listener.mock.calls[0][0];
160
+ expect(payload.error).toBe(err);
161
+ expect(payload.isOperational).toBe(true);
162
+ });
163
+
164
+ it('includes timestamp in log entry', () => {
165
+ const logger = makeLogger();
166
+ const handler = new ErrorHandler({ logger });
167
+ const err = new ValidationError('errors.validation');
168
+
169
+ handler.handle(err, {});
170
+
171
+ const loggedEntry = logger.warn.mock.calls[0][0];
172
+ expect(loggedEntry.timestamp).toBeDefined();
173
+ expect(new Date(loggedEntry.timestamp).toString()).not.toBe('Invalid Date');
174
+ });
175
+
176
+ it('falls back to INTERNAL_ERROR code for plain errors', () => {
177
+ const logger = makeLogger();
178
+ const handler = new ErrorHandler({ logger });
179
+ const err = new Error('plain error');
180
+
181
+ const result = handler.handle(err);
182
+
183
+ expect(result.error.code).toBe('INTERNAL_ERROR');
184
+ expect(result.error.statusCode).toBe(500);
185
+ expect(result.error.messageKey).toBe('errors.internal');
186
+ });
187
+ });
188
+ });
@@ -0,0 +1,68 @@
1
+ const BaseError = require('./BaseError');
2
+
3
+ class ValidationError extends BaseError {
4
+ constructor(messageKey, options = {}) {
5
+ super(messageKey, { code: 'VALIDATION_ERROR', statusCode: 400, ...options });
6
+ }
7
+ }
8
+
9
+ class NotFoundError extends BaseError {
10
+ constructor(messageKey, options = {}) {
11
+ super(messageKey, { code: 'NOT_FOUND', statusCode: 404, ...options });
12
+ }
13
+ }
14
+
15
+ class ConflictError extends BaseError {
16
+ constructor(messageKey, options = {}) {
17
+ super(messageKey, { code: 'CONFLICT', statusCode: 409, ...options });
18
+ }
19
+ }
20
+
21
+ class AuthorizationError extends BaseError {
22
+ constructor(messageKey, options = {}) {
23
+ super(messageKey, { code: 'AUTHORIZATION_ERROR', statusCode: 403, ...options });
24
+ }
25
+ }
26
+
27
+ class ExternalServiceError extends BaseError {
28
+ constructor(messageKey, options = {}) {
29
+ super(messageKey, { code: 'EXTERNAL_SERVICE_ERROR', statusCode: 502, ...options });
30
+ }
31
+ }
32
+
33
+ class ConfigurationError extends BaseError {
34
+ constructor(messageKey, options = {}) {
35
+ super(messageKey, { code: 'CONFIGURATION_ERROR', statusCode: 500, ...options });
36
+ }
37
+ }
38
+
39
+ class PluginError extends BaseError {
40
+ constructor(messageKey, options = {}) {
41
+ super(messageKey, { code: 'PLUGIN_ERROR', statusCode: 500, ...options });
42
+ }
43
+ }
44
+
45
+ class GraphExecutionError extends BaseError {
46
+ constructor(messageKey, options = {}) {
47
+ super(messageKey, { code: 'GRAPH_EXECUTION_ERROR', statusCode: 500, ...options });
48
+ }
49
+ }
50
+
51
+ class BotError extends BaseError {
52
+ constructor(messageKey, options = {}) {
53
+ super(messageKey, { code: 'BOT_ERROR', statusCode: 500, ...options });
54
+ }
55
+ }
56
+
57
+ module.exports = {
58
+ BaseError,
59
+ ValidationError,
60
+ NotFoundError,
61
+ ConflictError,
62
+ AuthorizationError,
63
+ ExternalServiceError,
64
+ ConfigurationError,
65
+ PluginError,
66
+ GraphExecutionError,
67
+ BotError,
68
+ };
@@ -0,0 +1,66 @@
1
+ class BatchingUtility {
2
+ constructor({ windowMs = 10, maxSize = 100 } = {}) {
3
+ this.windowMs = windowMs;
4
+ this.maxSize = maxSize;
5
+ this._queue = [];
6
+ this._timer = null;
7
+ this._resolvers = [];
8
+ }
9
+
10
+ add(operation) {
11
+ return new Promise((resolve, reject) => {
12
+ this._queue.push({ operation, resolve, reject });
13
+ this._resolvers.push({ resolve, reject });
14
+
15
+ if (this._queue.length >= this.maxSize) {
16
+ this._flush();
17
+ return;
18
+ }
19
+
20
+ if (!this._timer) {
21
+ this._timer = setTimeout(() => this._flush(), this.windowMs);
22
+ }
23
+ });
24
+ }
25
+
26
+ async _flush() {
27
+ if (this._timer) {
28
+ clearTimeout(this._timer);
29
+ this._timer = null;
30
+ }
31
+
32
+ const batch = this._queue.splice(0);
33
+ if (batch.length === 0) return;
34
+
35
+ for (const { operation, resolve, reject } of batch) {
36
+ try {
37
+ const result = await operation();
38
+ resolve(result);
39
+ } catch (err) {
40
+ reject(err);
41
+ }
42
+ }
43
+ }
44
+
45
+ async flush() {
46
+ return this._flush();
47
+ }
48
+
49
+ getPendingCount() {
50
+ return this._queue.length;
51
+ }
52
+
53
+ destroy() {
54
+ if (this._timer) {
55
+ clearTimeout(this._timer);
56
+ this._timer = null;
57
+ }
58
+ const err = new Error('BatchingUtility destroyed');
59
+ for (const { reject } of this._queue) {
60
+ reject(err);
61
+ }
62
+ this._queue = [];
63
+ }
64
+ }
65
+
66
+ module.exports = BatchingUtility;
@@ -0,0 +1,103 @@
1
+ const { EventEmitter } = require('events');
2
+ const { ExternalServiceError } = require('../errors/index');
3
+
4
+ const STATE = { CLOSED: 'CLOSED', OPEN: 'OPEN', HALF_OPEN: 'HALF_OPEN' };
5
+
6
+ class CircuitBreaker extends EventEmitter {
7
+ constructor({ failureThreshold = 5, successThreshold = 2, timeout = 60000, logger } = {}) {
8
+ super();
9
+ this.failureThreshold = failureThreshold;
10
+ this.successThreshold = successThreshold;
11
+ this.timeout = timeout;
12
+ this.logger = logger;
13
+
14
+ this.state = STATE.CLOSED;
15
+ this.failureCount = 0;
16
+ this.successCount = 0;
17
+ this.nextAttempt = null;
18
+ this._cachedResponse = null;
19
+ }
20
+
21
+ async call(fn, fallback = null) {
22
+ if (this.state === STATE.OPEN) {
23
+ if (Date.now() < this.nextAttempt) {
24
+ if (fallback !== null) return typeof fallback === 'function' ? fallback() : fallback;
25
+ throw new ExternalServiceError('errors.circuitBreaker.open');
26
+ }
27
+ this._transition(STATE.HALF_OPEN);
28
+ }
29
+
30
+ try {
31
+ const result = await fn();
32
+ this._onSuccess(result);
33
+ return result;
34
+ } catch (err) {
35
+ this._onFailure(err);
36
+ if (fallback !== null) return typeof fallback === 'function' ? fallback() : fallback;
37
+ throw err;
38
+ }
39
+ }
40
+
41
+ _onSuccess(result) {
42
+ this._cachedResponse = result;
43
+ this.failureCount = 0;
44
+
45
+ if (this.state === STATE.HALF_OPEN) {
46
+ this.successCount++;
47
+ if (this.successCount >= this.successThreshold) {
48
+ this._transition(STATE.CLOSED);
49
+ }
50
+ }
51
+ }
52
+
53
+ _onFailure(err) {
54
+ this.failureCount++;
55
+ this.successCount = 0;
56
+
57
+ if (this.state === STATE.HALF_OPEN || this.failureCount >= this.failureThreshold) {
58
+ this._transition(STATE.OPEN);
59
+ }
60
+ }
61
+
62
+ _transition(newState) {
63
+ const prevState = this.state;
64
+ this.state = newState;
65
+
66
+ if (newState === STATE.OPEN) {
67
+ this.nextAttempt = Date.now() + this.timeout;
68
+ this.successCount = 0;
69
+ } else if (newState === STATE.CLOSED) {
70
+ this.failureCount = 0;
71
+ this.successCount = 0;
72
+ this.nextAttempt = null;
73
+ } else if (newState === STATE.HALF_OPEN) {
74
+ this.successCount = 0;
75
+ }
76
+
77
+ if (this.logger) {
78
+ this.logger.warn(
79
+ { from: prevState, to: newState, failureCount: this.failureCount },
80
+ 'errors.circuitBreaker.stateChange'
81
+ );
82
+ }
83
+
84
+ this.emit('stateChange', { from: prevState, to: newState });
85
+ }
86
+
87
+ getState() {
88
+ return this.state;
89
+ }
90
+
91
+ getStats() {
92
+ return {
93
+ state: this.state,
94
+ failureCount: this.failureCount,
95
+ successCount: this.successCount,
96
+ nextAttempt: this.nextAttempt,
97
+ };
98
+ }
99
+ }
100
+
101
+ CircuitBreaker.STATE = STATE;
102
+
103
+ module.exports = CircuitBreaker;
@@ -0,0 +1,81 @@
1
+ const { ExternalServiceError } = require('../errors/index');
2
+
3
+ class ConnectionPool {
4
+ constructor({ factory, min = 2, max = 10, acquireTimeout = 5000, logger } = {}) {
5
+ this.factory = factory;
6
+ this.min = min;
7
+ this.max = max;
8
+ this.acquireTimeout = acquireTimeout;
9
+ this.logger = logger;
10
+ this.idle = [];
11
+ this.total = 0;
12
+ this.queue = [];
13
+ }
14
+
15
+ async acquire() {
16
+ if (this.idle.length > 0) {
17
+ return this.idle.pop();
18
+ }
19
+
20
+ if (this.total < this.max) {
21
+ this.total++;
22
+ try {
23
+ return await this.factory.create();
24
+ } catch (err) {
25
+ this.total--;
26
+ throw new ExternalServiceError('errors.connectionPool.createFailed', { cause: err });
27
+ }
28
+ }
29
+
30
+ return new Promise((resolve, reject) => {
31
+ const timer = setTimeout(() => {
32
+ const idx = this.queue.findIndex(item => item.resolve === resolve);
33
+ if (idx !== -1) this.queue.splice(idx, 1);
34
+ reject(new ExternalServiceError('errors.connectionPool.acquireTimeout'));
35
+ }, this.acquireTimeout);
36
+
37
+ this.queue.push({
38
+ resolve: (conn) => {
39
+ clearTimeout(timer);
40
+ resolve(conn);
41
+ },
42
+ reject,
43
+ });
44
+ });
45
+ }
46
+
47
+ release(connection) {
48
+ if (this.queue.length > 0) {
49
+ const waiter = this.queue.shift();
50
+ waiter.resolve(connection);
51
+ return;
52
+ }
53
+ this.idle.push(connection);
54
+ }
55
+
56
+ async destroy() {
57
+ const connections = [...this.idle];
58
+ this.idle = [];
59
+ this.queue.forEach(({ reject }) =>
60
+ reject(new ExternalServiceError('errors.connectionPool.destroyed'))
61
+ );
62
+ this.queue = [];
63
+ this.total = 0;
64
+ await Promise.all(connections.map(conn => this.factory.destroy(conn)));
65
+ }
66
+
67
+ getStats() {
68
+ const pending = this.queue.length;
69
+ const idle = this.idle.length;
70
+ const total = this.total;
71
+ const utilization = total > 0 ? (total - idle) / total : 0;
72
+
73
+ if (utilization > 0.8 && this.logger) {
74
+ this.logger.warn({ utilization, total, idle, pending }, 'errors.connectionPool.highUtilization');
75
+ }
76
+
77
+ return { total, idle, pending, utilization };
78
+ }
79
+ }
80
+
81
+ module.exports = ConnectionPool;
@@ -0,0 +1,64 @@
1
+ class RateLimiter {
2
+ constructor({ windowMs = 60000, max = 100, keyPrefix = '' } = {}) {
3
+ this.windowMs = windowMs;
4
+ this.max = max;
5
+ this.keyPrefix = keyPrefix;
6
+ this.store = new Map();
7
+ }
8
+
9
+ _getKey(key) {
10
+ return this.keyPrefix ? `${this.keyPrefix}:${key}` : key;
11
+ }
12
+
13
+ _prune(timestamps, now) {
14
+ const cutoff = now - this.windowMs;
15
+ let i = 0;
16
+ while (i < timestamps.length && timestamps[i] <= cutoff) i++;
17
+ return timestamps.slice(i);
18
+ }
19
+
20
+ check(key) {
21
+ const storeKey = this._getKey(key);
22
+ const now = Date.now();
23
+ const timestamps = this._prune(this.store.get(storeKey) || [], now);
24
+
25
+ const resetAt = timestamps.length > 0 ? timestamps[0] + this.windowMs : now + this.windowMs;
26
+ const allowed = timestamps.length < this.max;
27
+
28
+ if (allowed) {
29
+ timestamps.push(now);
30
+ this.store.set(storeKey, timestamps);
31
+ return { allowed: true, remaining: this.max - timestamps.length, resetAt };
32
+ }
33
+
34
+ const retryAfter = Math.ceil((resetAt - now) / 1000);
35
+ this.store.set(storeKey, timestamps);
36
+ return { allowed: false, remaining: 0, resetAt, retryAfter };
37
+ }
38
+
39
+ reset(key) {
40
+ this.store.delete(this._getKey(key));
41
+ }
42
+
43
+ middleware() {
44
+ return (req, res, next) => {
45
+ const key = req.user?.id ? String(req.user.id) : req.ip;
46
+ const result = this.check(key);
47
+
48
+ res.set('X-RateLimit-Limit', String(this.max));
49
+ res.set('X-RateLimit-Remaining', String(result.remaining));
50
+ res.set('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
51
+
52
+ if (!result.allowed) {
53
+ return res.status(429).json({
54
+ error: 'errors.rateLimit.exceeded',
55
+ retryAfter: result.retryAfter,
56
+ });
57
+ }
58
+
59
+ next();
60
+ };
61
+ }
62
+ }
63
+
64
+ module.exports = RateLimiter;
@@ -0,0 +1,86 @@
1
+ const BatchingUtility = require('../BatchingUtility');
2
+
3
+ describe('BatchingUtility', () => {
4
+ afterEach(() => {
5
+ jest.useRealTimers();
6
+ });
7
+
8
+ it('executes operations and resolves promises', async () => {
9
+ const batcher = new BatchingUtility({ windowMs: 50 });
10
+ const p = batcher.add(() => 42);
11
+ await batcher.flush();
12
+ expect(await p).toBe(42);
13
+ });
14
+
15
+ it('batches multiple operations within window', async () => {
16
+ jest.useFakeTimers();
17
+ const order = [];
18
+ const batcher = new BatchingUtility({ windowMs: 100 });
19
+
20
+ const p1 = batcher.add(() => { order.push(1); return 1; });
21
+ const p2 = batcher.add(() => { order.push(2); return 2; });
22
+ const p3 = batcher.add(() => { order.push(3); return 3; });
23
+
24
+ await batcher.flush();
25
+
26
+ expect(order).toEqual([1, 2, 3]);
27
+ expect(await p1).toBe(1);
28
+ expect(await p2).toBe(2);
29
+ expect(await p3).toBe(3);
30
+ });
31
+
32
+ it('flushes immediately when maxSize is reached', async () => {
33
+ const batcher = new BatchingUtility({ windowMs: 10000, maxSize: 2 });
34
+ const results = [];
35
+ const p1 = batcher.add(() => results.push(1));
36
+ const p2 = batcher.add(() => results.push(2));
37
+ await Promise.all([p1, p2]);
38
+ expect(results).toEqual([1, 2]);
39
+ });
40
+
41
+ it('preserves operation order', async () => {
42
+ const batcher = new BatchingUtility({ windowMs: 50 });
43
+ const order = [];
44
+ batcher.add(() => order.push('a'));
45
+ batcher.add(() => order.push('b'));
46
+ batcher.add(() => order.push('c'));
47
+ await batcher.flush();
48
+ expect(order).toEqual(['a', 'b', 'c']);
49
+ });
50
+
51
+ it('rejects promise when operation throws', async () => {
52
+ const batcher = new BatchingUtility({ windowMs: 50 });
53
+ const p = batcher.add(() => { throw new Error('fail'); });
54
+ await batcher.flush();
55
+ await expect(p).rejects.toThrow('fail');
56
+ });
57
+
58
+ it('handles mixed success and failure in same batch', async () => {
59
+ const batcher = new BatchingUtility({ windowMs: 50 });
60
+ const p1 = batcher.add(() => 'ok');
61
+ const p2 = batcher.add(() => { throw new Error('fail'); });
62
+ const p3 = batcher.add(() => 'also ok');
63
+ await batcher.flush();
64
+ expect(await p1).toBe('ok');
65
+ await expect(p2).rejects.toThrow('fail');
66
+ expect(await p3).toBe('also ok');
67
+ });
68
+
69
+ it('getPendingCount returns correct count', () => {
70
+ jest.useFakeTimers();
71
+ const batcher = new BatchingUtility({ windowMs: 1000 });
72
+ batcher.add(() => 1);
73
+ batcher.add(() => 2);
74
+ expect(batcher.getPendingCount()).toBe(2);
75
+ });
76
+
77
+ it('destroy rejects all pending operations', async () => {
78
+ jest.useFakeTimers();
79
+ const batcher = new BatchingUtility({ windowMs: 1000 });
80
+ const p1 = batcher.add(() => 1);
81
+ const p2 = batcher.add(() => 2);
82
+ batcher.destroy();
83
+ await expect(p1).rejects.toThrow();
84
+ await expect(p2).rejects.toThrow();
85
+ });
86
+ });