@pilaf/backends 1.0.1 → 1.2.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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * DockerLogCollector - Collect logs from Docker containers
3
+ *
4
+ * Streams log output from Docker containers using the Dockerode API.
5
+ * Handles reconnection with exponential backoff and supports both
6
+ * following and historical log retrieval.
7
+ *
8
+ * Responsibilities (MECE):
9
+ * - ONLY: Connect to Docker daemon and stream container logs
10
+ * - NOT: Parse log content (LogParser's responsibility)
11
+ * - NOT: Handle container lifecycle (user's responsibility)
12
+ *
13
+ * @class
14
+ * @extends LogCollector
15
+ *
16
+ * Usage:
17
+ * const collector = new DockerLogCollector();
18
+ * await collector.connect({
19
+ * containerName: 'minecraft-server',
20
+ * follow: true,
21
+ * tail: 100
22
+ * });
23
+ * collector.on('data', (line) => console.log(line));
24
+ */
25
+
26
+ const { LogCollector } = require('../core/LogCollector.js');
27
+ const { DockerConnectionError } = require('../errors/index.js');
28
+
29
+ class DockerLogCollector extends LogCollector {
30
+ /**
31
+ * Create a DockerLogCollector
32
+ * @param {Object} options - Collector options
33
+ * @param {Object} [options.dockerodeOptions] - Options to pass to Dockerode
34
+ * @param {string} [options.dockerodeOptions.socketPath] - Docker socket path (default: /var/run/docker.sock)
35
+ * @param {number} [options.reconnectDelay=1000] - Initial reconnection delay in ms
36
+ * @param {number} [options.maxReconnectDelay=30000] - Maximum reconnection delay in ms
37
+ * @param {number} [options.reconnectAttempts=5] - Maximum reconnection attempts
38
+ */
39
+ constructor(options = {}) {
40
+ super();
41
+
42
+ /**
43
+ * Dockerode options
44
+ * @private
45
+ * @type {Object}
46
+ */
47
+ this._dockerodeOptions = options?.dockerodeOptions || {};
48
+
49
+ /**
50
+ * Reconnection configuration
51
+ * @private
52
+ * @type {Object}
53
+ */
54
+ this._reconnectConfig = {
55
+ delay: options?.reconnectDelay || 1000,
56
+ maxDelay: options?.maxReconnectDelay || 30000,
57
+ attempts: options?.reconnectAttempts || 5
58
+ };
59
+
60
+ /**
61
+ * Dockerode instance (lazy loaded)
62
+ * @private
63
+ * @type {Object|null}
64
+ */
65
+ this._docker = null;
66
+
67
+ /**
68
+ * Docker container instance
69
+ * @private
70
+ * @type {Object|null}
71
+ */
72
+ this._container = null;
73
+
74
+ /**
75
+ * Log stream instance
76
+ * @private
77
+ * @type {Object|null}
78
+ */
79
+ this._logStream = null;
80
+
81
+ /**
82
+ * Reconnection timeout ID
83
+ * @private
84
+ * @type {NodeJS.Timeout|null}
85
+ */
86
+ this._reconnectTimeout = null;
87
+
88
+ /**
89
+ * Current reconnection attempt count
90
+ * @private
91
+ * @type {number}
92
+ */
93
+ this._reconnectCount = 0;
94
+
95
+ /**
96
+ * Stream options for Docker logs
97
+ * @private
98
+ * @type {Object}
99
+ */
100
+ this._streamOptions = {
101
+ follow: true,
102
+ stdout: true,
103
+ stderr: true,
104
+ tail: 0
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Connect to Docker container and stream logs
110
+ *
111
+ * @param {Object} config - Connection configuration
112
+ * @param {string} config.containerName - Name or ID of the container
113
+ * @param {boolean} [config.follow=true] - Follow log stream
114
+ * @param {number} [config.tail=0] - Number of lines from history (0 = all)
115
+ * @param {boolean} [config.stdout=true] - Include stdout
116
+ * @param {boolean} [config.stderr=true] - Include stderr
117
+ * @param {boolean} [config.disableAutoReconnect=false] - Disable automatic reconnection
118
+ * @returns {Promise<void>}
119
+ * @throws {DockerConnectionError} If Docker connection fails
120
+ */
121
+ async connect(config) {
122
+ if (this._connected) {
123
+ await this.disconnect();
124
+ }
125
+
126
+ this._config = config;
127
+ this._streamOptions = {
128
+ follow: config?.follow !== false,
129
+ stdout: config?.stdout !== false,
130
+ stderr: config?.stderr !== false,
131
+ tail: config?.tail || 0
132
+ };
133
+
134
+ try {
135
+ // Lazy load Dockerode
136
+ if (!this._docker) {
137
+ const Docker = require('dockerode');
138
+ this._docker = new Docker(this._dockerodeOptions);
139
+ }
140
+
141
+ // Get container
142
+ const containerName = config?.containerName;
143
+ if (!containerName) {
144
+ throw new DockerConnectionError(
145
+ 'Container name is required',
146
+ { config: this._streamOptions }
147
+ );
148
+ }
149
+
150
+ this._container = this._docker.getContainer(containerName);
151
+
152
+ // Verify container exists
153
+ try {
154
+ await this._container.inspect();
155
+ } catch (inspectError) {
156
+ throw new DockerConnectionError(
157
+ `Container not found: ${containerName}`,
158
+ { containerName },
159
+ inspectError
160
+ );
161
+ }
162
+
163
+ // Create log stream
164
+ this._logStream = await this._container.logs(this._streamOptions);
165
+
166
+ // Set up stream handlers
167
+ this._setupStreamHandlers(this._logStream);
168
+
169
+ this._connected = true;
170
+ // Only reset reconnect count on initial connection (not on reconnection)
171
+ if (!this._reconnectCount) {
172
+ this._reconnectCount = 0;
173
+ }
174
+
175
+ this.emit('connected');
176
+ } catch (error) {
177
+ // Re-throw to let caller handle the error
178
+ throw error;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Set up event handlers for the log stream
184
+ *
185
+ * @private
186
+ * @param {Object} stream - Docker log stream
187
+ * @returns {void}
188
+ */
189
+ _setupStreamHandlers(stream) {
190
+ // Docker logs are Buffers, need to decode and strip ANSI codes
191
+ stream.on('data', (chunk) => {
192
+ if (!this._connected) return;
193
+
194
+ // Decode buffer to string
195
+ const line = chunk.toString('utf8').trim();
196
+
197
+ // Skip empty lines
198
+ if (!line) return;
199
+
200
+ // Strip ANSI color codes if present
201
+ const cleanLine = this._stripAnsiCodes(line);
202
+
203
+ this._emitData(cleanLine);
204
+ });
205
+
206
+ stream.on('error', (error) => {
207
+ this._emitError(new DockerConnectionError(
208
+ 'Log stream error',
209
+ { containerName: this._config?.containerName },
210
+ error
211
+ ));
212
+ });
213
+
214
+ stream.on('end', () => {
215
+ this._handleStreamEnd();
216
+ });
217
+
218
+ stream.on('close', () => {
219
+ this._handleStreamEnd();
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Handle log stream termination
225
+ *
226
+ * @private
227
+ * @returns {void}
228
+ */
229
+ _handleStreamEnd() {
230
+ const shouldReconnect = this._config?.disableAutoReconnect !== true &&
231
+ this._reconnectCount < this._reconnectConfig.attempts;
232
+
233
+ if (shouldReconnect && this._connected) {
234
+ this._scheduleReconnect();
235
+ } else {
236
+ this._emitEnd();
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Schedule reconnection with exponential backoff
242
+ *
243
+ * @private
244
+ * @returns {void}
245
+ */
246
+ _scheduleReconnect() {
247
+ if (this._reconnectTimeout) {
248
+ clearTimeout(this._reconnectTimeout);
249
+ }
250
+
251
+ // Exponential backoff: delay * 2^attempt
252
+ const delay = Math.min(
253
+ this._reconnectConfig.delay * Math.pow(2, this._reconnectCount),
254
+ this._reconnectConfig.maxDelay
255
+ );
256
+
257
+ this._reconnectCount++;
258
+
259
+ this.emit('reconnecting', {
260
+ attempt: this._reconnectCount,
261
+ maxAttempts: this._reconnectConfig.attempts,
262
+ delay
263
+ });
264
+
265
+ this._reconnectTimeout = setTimeout(async () => {
266
+ try {
267
+ await this.connect(this._config);
268
+ } catch (error) {
269
+ this._emitError(error);
270
+ }
271
+ }, delay);
272
+ }
273
+
274
+ /**
275
+ * Strip ANSI escape codes from log line
276
+ *
277
+ * @private
278
+ * @param {string} line - Line that may contain ANSI codes
279
+ * @returns {string} - Clean line without ANSI codes
280
+ */
281
+ _stripAnsiCodes(line) {
282
+ // Remove ANSI escape sequences
283
+ // Format: ESC[...m where ESC is \x1b or \u001b
284
+ const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
285
+ return line.replace(ansiRegex, '');
286
+ }
287
+
288
+ /**
289
+ * Disconnect from Docker container
290
+ *
291
+ * @returns {Promise<void>}
292
+ */
293
+ async disconnect() {
294
+ // Cancel pending reconnection
295
+ if (this._reconnectTimeout) {
296
+ clearTimeout(this._reconnectTimeout);
297
+ this._reconnectTimeout = null;
298
+ }
299
+
300
+ // Destroy log stream
301
+ if (this._logStream) {
302
+ this._logStream.removeAllListeners();
303
+ if (typeof this._logStream.destroy === 'function') {
304
+ this._logStream.destroy();
305
+ }
306
+ this._logStream = null;
307
+ }
308
+
309
+ // Clear references
310
+ this._container = null;
311
+ this._docker = null;
312
+ this._connected = false;
313
+
314
+ this.emit('disconnected');
315
+ }
316
+
317
+ /**
318
+ * Get current reconnection status
319
+ *
320
+ * @returns {Object} - Reconnection status
321
+ * @property {number} attempt - Current attempt number
322
+ * @property {number} maxAttempts - Maximum attempts
323
+ * @property {boolean} reconnecting - Whether reconnection is pending
324
+ */
325
+ getReconnectStatus() {
326
+ return {
327
+ attempt: this._reconnectCount,
328
+ maxAttempts: this._reconnectConfig.attempts,
329
+ reconnecting: this._reconnectTimeout !== null
330
+ };
331
+ }
332
+ }
333
+
334
+ module.exports = { DockerLogCollector };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Log Collectors
3
+ *
4
+ * Concrete implementations of LogCollector for various log sources.
5
+ */
6
+
7
+ module.exports = {
8
+ DockerLogCollector: require('./DockerLogCollector.js').DockerLogCollector
9
+ };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * CommandRouter - Abstract base class for command routing
3
+ *
4
+ * Defines the interface for routing commands to the appropriate channel
5
+ * (bot chat, RCON, or log monitoring). Concrete implementations must
6
+ * extend this class and implement the abstract route() method.
7
+ *
8
+ * @abstract
9
+ * @class
10
+ *
11
+ * Responsibilities (MECE):
12
+ * - ONLY: Decide which channel should handle a command
13
+ * - NOT: Execute commands (backend's responsibility)
14
+ * - NOT: Parse responses (parser's responsibility)
15
+ *
16
+ * Routing Logic:
17
+ * - /data get commands → RCON (structured NBT responses)
18
+ * - /execute with 'run data' → RCON (structured queries)
19
+ * - useRcon option → RCON (forced routing)
20
+ * - expectLogResponse option → Log monitoring (event correlation)
21
+ * - Default → Bot chat (player commands)
22
+ *
23
+ * Usage:
24
+ * class SmartCommandRouter extends CommandRouter {
25
+ * route(command, context) {
26
+ * // Analyze command and return { channel, options }
27
+ * }
28
+ * }
29
+ */
30
+
31
+ class CommandRouter {
32
+ /**
33
+ * Routing channels
34
+ * @static
35
+ * @enum {string}
36
+ */
37
+ static get CHANNELS() {
38
+ return {
39
+ BOT: 'bot', // Send via bot.chat()
40
+ RCON: 'rcon', // Send via RCON
41
+ LOG: 'log' // Send via bot and wait for log response
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Create a CommandRouter
47
+ * @param {Object} options - Router options
48
+ * @param {Object} [options.rules] - Custom routing rules
49
+ * @throws {Error} Direct instantiation of abstract class
50
+ */
51
+ constructor(options = {}) {
52
+ // Prevent direct instantiation of abstract class
53
+ if (this.constructor === CommandRouter) {
54
+ throw new Error('CommandRouter is abstract and cannot be instantiated directly');
55
+ }
56
+
57
+ /**
58
+ * Custom routing rules
59
+ * @protected
60
+ * @type {Array<{pattern: string|RegExp, channel: string}>}
61
+ */
62
+ this._customRules = options?.rules ? Object.entries(options.rules).map(([pattern, channel]) => ({ pattern, channel })) : [];
63
+ }
64
+
65
+ /**
66
+ * Route a command to the appropriate channel
67
+ *
68
+ * Abstract method that must be implemented by concrete classes.
69
+ * Should analyze the command and context to determine the best channel.
70
+ *
71
+ * @abstract
72
+ * @param {string} command - The command to route
73
+ * @param {Object} context - Routing context
74
+ * @param {Object} context.options - Command options (useRcon, expectLogResponse, etc.)
75
+ * @param {string} [context.username] - Bot username (for correlation)
76
+ * @param {Object} [context.backend] - Backend instance (for advanced routing)
77
+ * @returns {Object} Routing decision
78
+ * @property {string} channel - One of: 'bot', 'rcon', 'log'
79
+ * @property {Object} options - Options to pass to the channel
80
+ * @example
81
+ * // Returns:
82
+ * // { channel: 'rcon', options: { timeout: 10000 } }
83
+ */
84
+ route(command, context) {
85
+ throw new Error('Method "route()" must be implemented by subclass');
86
+ }
87
+
88
+ /**
89
+ * Add a custom routing rule
90
+ *
91
+ * @param {string|RegExp} pattern - Command pattern to match
92
+ * @param {string} channel - Channel to route to ('bot', 'rcon', 'log')
93
+ * @returns {void}
94
+ * @throws {Error} If channel is invalid
95
+ */
96
+ addRule(pattern, channel) {
97
+ const validChannels = Object.values(CommandRouter.CHANNELS);
98
+ if (!validChannels.includes(channel)) {
99
+ throw new Error(`Invalid channel: ${channel}. Must be one of: ${validChannels.join(', ')}`);
100
+ }
101
+
102
+ // Remove existing rule with same pattern (if any)
103
+ this.removeRule(pattern);
104
+
105
+ // Add new rule
106
+ this._customRules.push({ pattern, channel });
107
+ }
108
+
109
+ /**
110
+ * Remove a custom routing rule
111
+ *
112
+ * @param {string|RegExp} pattern - Pattern to remove
113
+ * @returns {boolean} - True if rule was removed, false if not found
114
+ */
115
+ removeRule(pattern) {
116
+ const initialLength = this._customRules.length;
117
+ this._customRules = this._customRules.filter(rule => {
118
+ // For RegExp, we need to compare by stringification
119
+ if (pattern instanceof RegExp && rule.pattern instanceof RegExp) {
120
+ return rule.pattern.toString() !== pattern.toString();
121
+ }
122
+ return rule.pattern !== pattern;
123
+ });
124
+ return this._customRules.length < initialLength;
125
+ }
126
+
127
+ /**
128
+ * Check if a command matches a pattern
129
+ *
130
+ * Protected helper method for concrete implementations.
131
+ *
132
+ * @protected
133
+ * @param {string} command - Command to check
134
+ * @param {string|RegExp} pattern - Pattern to match against
135
+ * @returns {boolean} - True if command matches pattern
136
+ */
137
+ _matchesPattern(command, pattern) {
138
+ if (pattern instanceof RegExp) {
139
+ return pattern.test(command);
140
+ }
141
+ return command.startsWith(pattern);
142
+ }
143
+
144
+ /**
145
+ * Get custom rules
146
+ *
147
+ * @returns {Array<{pattern: string|RegExp, channel: string}>} - Copy of custom rules array
148
+ */
149
+ getRules() {
150
+ return [...this._customRules];
151
+ }
152
+ }
153
+
154
+ module.exports = { CommandRouter };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * CorrelationStrategy - Abstract base class for command response correlation
3
+ *
4
+ * Defines the interface for matching command responses in log streams.
5
+ * Concrete implementations must extend this class and implement the
6
+ * abstract correlate() method.
7
+ *
8
+ * @abstract
9
+ * @class
10
+ *
11
+ * Responsibilities (MECE):
12
+ * - ONLY: Match log events to commands
13
+ * - NOT: Parse logs (LogParser's responsibility)
14
+ * - NOT: Execute commands (backend's responsibility)
15
+ * - NOT: Store events (LogMonitor's responsibility)
16
+ *
17
+ * Strategy Hierarchy (in order of reliability):
18
+ * 1. Tag-based - Most reliable (uses entity tags)
19
+ * 2. Username-based - Moderately reliable (uses username + timestamp)
20
+ * 3. Sequential - Fragile (assumes order preservation)
21
+ *
22
+ * Usage:
23
+ * class TagCorrelationStrategy extends CorrelationStrategy {
24
+ * async correlate(command, eventStream, timeout) {
25
+ * // Inject tag and wait for matching response
26
+ * }
27
+ * }
28
+ */
29
+
30
+ const { ResponseTimeoutError } = require('../errors/index.js');
31
+
32
+ class CorrelationStrategy {
33
+ /**
34
+ * Create a CorrelationStrategy
35
+ * @param {Object} options - Strategy options
36
+ * @param {number} [options.timeout=5000] - Default timeout in milliseconds
37
+ * @param {number} [options.window=2000] - Time window for correlation (ms)
38
+ * @throws {Error} Direct instantiation of abstract class
39
+ */
40
+ constructor(options = {}) {
41
+ // Prevent direct instantiation of abstract class
42
+ if (this.constructor === CorrelationStrategy) {
43
+ throw new Error('CorrelationStrategy is abstract and cannot be instantiated directly');
44
+ }
45
+
46
+ /**
47
+ * Default timeout for correlation
48
+ * @protected
49
+ * @type {number}
50
+ */
51
+ this._defaultTimeout = options?.timeout || 5000;
52
+
53
+ /**
54
+ * Time window for correlation (ms)
55
+ * Used by time-based strategies
56
+ * @protected
57
+ * @type {number}
58
+ */
59
+ this._correlationWindow = options?.window || 2000;
60
+ }
61
+
62
+ /**
63
+ * Correlate a command with its response from the event stream
64
+ *
65
+ * Abstract method that must be implemented by concrete classes.
66
+ * Should wait for matching event and return the response, or throw
67
+ * CorrelationError if timeout occurs.
68
+ *
69
+ * @abstract
70
+ * @param {string} command - The command that was sent
71
+ * @param {AsyncIterable<Object>} eventStream - Stream of parsed events
72
+ * @param {number} [timeout] - Timeout in milliseconds (uses default if not provided)
73
+ * @returns {Promise<Object>} - The correlated response event
74
+ * @throws {ResponseTimeoutError} - If correlation fails or times out
75
+ * @example
76
+ * // Returns:
77
+ * // {
78
+ * // type: 'teleport',
79
+ * // data: { player: 'TestPlayer', position: { x: 100, y: 64, z: 100 } },
80
+ * // raw: '[12:34:56] Teleported TestPlayer to 100.0, 64.0, 100.0'
81
+ * // }
82
+ */
83
+ async correlate(command, eventStream, timeout) {
84
+ throw new Error('Method "correlate()" must be implemented by subclass');
85
+ }
86
+
87
+ /**
88
+ * Generate a unique command ID for correlation
89
+ *
90
+ * Protected helper for concrete implementations.
91
+ * Generates a UUID-like string for tagging commands.
92
+ *
93
+ * @protected
94
+ * @returns {string} - Unique command ID
95
+ */
96
+ _generateCommandId() {
97
+ return `pilaf-cmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
98
+ }
99
+
100
+ /**
101
+ * Inject correlation marker into a command
102
+ *
103
+ * Protected helper for tag-based strategies.
104
+ * Modifies the command to include a tag or marker.
105
+ *
106
+ * @protected
107
+ * @param {string} command - Original command
108
+ * @param {string} marker - Correlation marker
109
+ * @returns {string} - Modified command with marker
110
+ * @example
111
+ * _injectMarker('/tp @s ~ ~ ~', 'pilaf-cmd-123')
112
+ * // Returns: '/tp @s[tag=pilaf-cmd-123] ~ ~ ~'
113
+ */
114
+ _injectMarker(command, marker) {
115
+ // This is a default implementation - concrete classes may override
116
+ // For Minecraft commands, we can use entity tags
117
+ if (command.includes('@s')) {
118
+ return command.replace('@s', `@s[tag=${marker}]`);
119
+ }
120
+ if (command.includes('@p')) {
121
+ return command.replace('@p', `@p[tag=${marker}]`);
122
+ }
123
+ if (command.includes('@a')) {
124
+ return command.replace('@a', `@a[tag=${marker}]`);
125
+ }
126
+ if (command.includes('@e')) {
127
+ return command.replace('@e', `@e[tag=${marker}]`);
128
+ }
129
+ return command;
130
+ }
131
+
132
+ /**
133
+ * Wait for timeout to expire
134
+ *
135
+ * Protected helper for timeout handling.
136
+ *
137
+ * @protected
138
+ * @param {number} timeout - Timeout in milliseconds
139
+ * @returns {Promise<never>} - Rejects after timeout
140
+ */
141
+ async _waitForTimeout(timeout) {
142
+ return new Promise((_, reject) => {
143
+ setTimeout(() => {
144
+ reject(new ResponseTimeoutError('unknown', timeout));
145
+ }, timeout);
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Check if an event matches correlation criteria
151
+ *
152
+ * Protected helper for concrete implementations.
153
+ * Provides a default implementation that checks for the
154
+ * correlation marker in the event data or raw text.
155
+ *
156
+ * @protected
157
+ * @param {Object} event - Parsed event to check
158
+ * @param {string} marker - Correlation marker to match
159
+ * @returns {boolean} - True if event matches
160
+ */
161
+ _matchesEvent(event, marker) {
162
+ if (!event || !event.data) {
163
+ return false;
164
+ }
165
+
166
+ // Check if marker exists anywhere in the event
167
+ const eventString = JSON.stringify(event);
168
+ return eventString.includes(marker);
169
+ }
170
+ }
171
+
172
+ module.exports = { CorrelationStrategy };