@ceschiatti/redistail 0.0.1 → 0.0.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.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Main CLI program for redistail - Redis message monitoring utility.
3
+ *
4
+ * This module implements the main program effect that combines all services
5
+ * to create a complete Redis monitoring application. It handles CLI argument
6
+ * parsing, service initialization, signal handling, and message streaming.
7
+ *
8
+ * Requirements addressed:
9
+ * - 1.5: Help and version flag support
10
+ * - 2.5: Graceful shutdown for PubSub monitoring (Ctrl+C handling)
11
+ * - 3.5: Graceful shutdown for Stream monitoring (Ctrl+C handling)
12
+ * - 6.3: Topic validation and error handling
13
+ * - 6.4: Redis error recovery and continued monitoring
14
+ */
15
+
16
+ import { BunRuntime } from '@effect/platform-bun';
17
+ import { Console, Duration, Effect, pipe, Schedule, Stream } from 'effect';
18
+ // Note: RedisPubSub and RedisStream are provided transitively via PubSubMonitorService and StreamMonitorService
19
+ import { CLIConfigService, CLIConfigServiceLive } from './config-service.js';
20
+ import { DisplayServiceTag, type DisplayService } from './display-service.js';
21
+ import { PubSubAppLive, StreamAppLive } from './layers.js';
22
+ import { PubSubMonitorService } from './pubsub-monitor-service.js';
23
+ import { SignalHandlerService } from './signal-handler-service.js';
24
+ import { StreamMonitorService } from './stream-monitor-service.js';
25
+ import type { CLIConfig, RedistailError } from './types.js';
26
+
27
+ // ============================================================================
28
+ // Main Program Implementation
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Main redistail program effect that combines all services
33
+ *
34
+ * This effect:
35
+ * 1. Parses CLI arguments and loads configuration
36
+ * 2. Handles help and version flags
37
+ * 3. Sets up signal handlers for graceful shutdown
38
+ * 4. Starts appropriate monitoring based on connection type
39
+ * 5. Streams and displays messages until interrupted
40
+ *
41
+ * Requirements 1.5, 2.5, 3.5, 6.3, 6.4
42
+ */
43
+ export const redistailProgram = (
44
+ args: string[],
45
+ ): Effect.Effect<
46
+ void,
47
+ RedistailError,
48
+ | CLIConfigService
49
+ | SignalHandlerService
50
+ | DisplayService
51
+ | PubSubMonitorService
52
+ | StreamMonitorService
53
+ > =>
54
+ Effect.gen(function* () {
55
+ // Parse CLI arguments
56
+ const cliConfigService = yield* CLIConfigService;
57
+ const cliConfig = yield* cliConfigService.parseArgs(args);
58
+
59
+ // Handle help and version flags (Requirement 1.5)
60
+ if (cliConfig.help) {
61
+ yield* cliConfigService.showHelp();
62
+ return;
63
+ }
64
+
65
+ if (cliConfig.version) {
66
+ yield* cliConfigService.showVersion();
67
+ return;
68
+ }
69
+
70
+ // Setup signal handlers for graceful shutdown (Requirements 2.5, 3.5)
71
+ const signalHandler = yield* SignalHandlerService;
72
+ yield* signalHandler.setupSignalHandlers();
73
+
74
+ // Get required services
75
+ const displayService = yield* DisplayServiceTag;
76
+
77
+ // Start monitoring based on connection type
78
+ yield* startMonitoring(cliConfig, displayService);
79
+ }) as Effect.Effect<
80
+ void,
81
+ RedistailError,
82
+ | CLIConfigService
83
+ | SignalHandlerService
84
+ | DisplayService
85
+ | PubSubMonitorService
86
+ | StreamMonitorService
87
+ >;
88
+
89
+ /**
90
+ * Start monitoring based on the connection type specified in CLI config
91
+ *
92
+ * This function handles both PubSub and Stream monitoring with proper
93
+ * error handling and recovery (Requirements 6.3, 6.4)
94
+ */
95
+ const startMonitoring = (
96
+ cliConfig: CLIConfig,
97
+ displayService: DisplayService,
98
+ ): Effect.Effect<
99
+ void,
100
+ RedistailError,
101
+ PubSubMonitorService | StreamMonitorService | CLIConfigService
102
+ > =>
103
+ Effect.gen(function* () {
104
+ // Validate topic name (Requirement 6.3)
105
+ if (!cliConfig.topicName || cliConfig.topicName.trim().length === 0) {
106
+ yield* displayService.outputError('āŒ Error: Topic name cannot be empty');
107
+ return yield* Effect.fail(
108
+ new Error('Invalid topic name') as RedistailError,
109
+ );
110
+ }
111
+
112
+ const topicName = cliConfig.topicName.trim();
113
+
114
+ // Log connection attempt to stderr
115
+ yield* displayService.outputError(
116
+ `šŸ”— Connecting to Redis ${cliConfig.connectionType} monitoring for topic: ${topicName}`,
117
+ );
118
+
119
+ if (cliConfig.connectionType === 'pubsub') {
120
+ yield* startPubSubMonitoring(topicName, displayService);
121
+ } else {
122
+ yield* startStreamMonitoring(topicName, displayService);
123
+ }
124
+ });
125
+
126
+ /**
127
+ * Start PubSub monitoring with error recovery
128
+ *
129
+ * Requirements 2.5, 6.4: Graceful shutdown and Redis error recovery
130
+ */
131
+ const startPubSubMonitoring = (
132
+ channel: string,
133
+ displayService: DisplayService,
134
+ ): Effect.Effect<void, RedistailError, PubSubMonitorService> =>
135
+ Effect.gen(function* () {
136
+ const pubSubMonitor = yield* PubSubMonitorService;
137
+
138
+ // Log successful connection
139
+ yield* displayService.outputError(
140
+ `āœ… Connected to PubSub channel: ${channel}`,
141
+ );
142
+ yield* displayService.outputError(
143
+ 'šŸ“” Waiting for messages... (Press Ctrl+C to exit)',
144
+ );
145
+
146
+ // Create message stream with error recovery (Requirement 6.4)
147
+ const messageStream = pubSubMonitor.monitor(channel).pipe(
148
+ Stream.mapEffect((message) =>
149
+ displayService.formatPubSubMessage(message),
150
+ ),
151
+ Stream.mapEffect((formatted) => displayService.outputMessage(formatted)),
152
+ // Retry on recoverable errors with exponential backoff
153
+ Stream.retry(
154
+ pipe(
155
+ Schedule.exponential(Duration.seconds(1)),
156
+ Schedule.intersect(Schedule.recurs(5)), // Max 5 retry attempts
157
+ Schedule.tapInput(
158
+ (error: unknown) =>
159
+ displayService.outputError(
160
+ `āš ļø Connection error, retrying... ${String(error)}`,
161
+ ) as Effect.Effect<void, never, never>,
162
+ ),
163
+ ),
164
+ ),
165
+ // Handle non-recoverable errors gracefully
166
+ Stream.catchAll((error: unknown) =>
167
+ Stream.fromEffect(
168
+ pipe(
169
+ Effect.all([
170
+ displayService.outputError(
171
+ `āŒ Fatal error in PubSub monitoring: ${String(error)}`,
172
+ ),
173
+ displayService.outputError(
174
+ 'šŸ’” Check Redis connection and try again',
175
+ ),
176
+ ]),
177
+ Effect.asVoid,
178
+ ) as Effect.Effect<void, never, never>,
179
+ ),
180
+ ),
181
+ ) as Stream.Stream<void, never, never>;
182
+
183
+ // Run the message stream until completion or interruption
184
+ yield* Stream.runDrain(messageStream);
185
+ });
186
+
187
+ /**
188
+ * Start Stream monitoring with error recovery
189
+ *
190
+ * Requirements 3.5, 6.4: Graceful shutdown and Redis error recovery
191
+ */
192
+ const startStreamMonitoring = (
193
+ streamKey: string,
194
+ displayService: DisplayService,
195
+ ): Effect.Effect<void, RedistailError, StreamMonitorService> =>
196
+ Effect.gen(function* () {
197
+ const streamMonitor = yield* StreamMonitorService;
198
+
199
+ // Log successful connection
200
+ yield* displayService.outputError(`āœ… Connected to Stream: ${streamKey}`);
201
+ yield* displayService.outputError(
202
+ 'šŸ“” Waiting for stream entries... (Press Ctrl+C to exit)',
203
+ );
204
+
205
+ // Create message stream with error recovery (Requirement 6.4)
206
+ const messageStream = streamMonitor.monitor(streamKey).pipe(
207
+ Stream.mapEffect((message) =>
208
+ displayService.formatStreamMessage(message),
209
+ ),
210
+ Stream.mapEffect((formatted) => displayService.outputMessage(formatted)),
211
+ // Retry on recoverable errors with exponential backoff
212
+ Stream.retry(
213
+ pipe(
214
+ Schedule.exponential(Duration.seconds(1)),
215
+ Schedule.intersect(Schedule.recurs(5)), // Max 5 retry attempts
216
+ Schedule.tapInput(
217
+ (error: unknown) =>
218
+ displayService.outputError(
219
+ `āš ļø Connection error, retrying... ${String(error)}`,
220
+ ) as Effect.Effect<void, never, never>,
221
+ ),
222
+ ),
223
+ ),
224
+ // Handle non-recoverable errors gracefully
225
+ Stream.catchAll((error: unknown) =>
226
+ Stream.fromEffect(
227
+ pipe(
228
+ Effect.all([
229
+ displayService.outputError(
230
+ `āŒ Fatal error in Stream monitoring: ${String(error)}`,
231
+ ),
232
+ displayService.outputError(
233
+ 'šŸ’” Check Redis connection and stream name, then try again',
234
+ ),
235
+ ]),
236
+ Effect.asVoid,
237
+ ) as Effect.Effect<void, never, never>,
238
+ ),
239
+ ),
240
+ ) as Stream.Stream<void, never, never>;
241
+
242
+ // Run the message stream until completion or interruption
243
+ yield* Stream.runDrain(messageStream);
244
+ });
245
+
246
+ // ============================================================================
247
+ // CLI Entry Point with Error Handling
248
+ // ============================================================================
249
+
250
+ /**
251
+ * CLI main function with comprehensive error handling and optimized layer selection
252
+ *
253
+ * This function provides the main entry point for the CLI binary with
254
+ * proper error handling, logging, and process management. It optimizes
255
+ * Redis connections by only loading the services needed for the specific mode:
256
+ *
257
+ * - PubSub mode: Only creates 2 Redis connections (publish + subscribe)
258
+ * - Stream mode: Only creates 2 Redis connections (producer + consumer)
259
+ * - Help/version: Uses minimal layer without Redis connections
260
+ * - Invalid args: Shows error without creating any connections
261
+ *
262
+ * This reduces Redis connections from 5 to just 2 per run, or 0 for help/errors.
263
+ */
264
+ export const cliMain = (args: string[]): Effect.Effect<void, never, never> => {
265
+ // Early validation - check for help/version flags or missing arguments
266
+ // This prevents unnecessary Redis connections for simple cases
267
+ const hasHelpFlag = args.includes('--help') || args.includes('-h');
268
+ const hasVersionFlag = args.includes('--version') || args.includes('-v');
269
+ const hasMissingArgs = args.length < 2 && !hasHelpFlag && !hasVersionFlag;
270
+
271
+ // Handle help/version/error cases without Redis connections
272
+ if (hasHelpFlag || hasVersionFlag || hasMissingArgs) {
273
+ const simpleProgram = Effect.gen(function* () {
274
+ const cliConfigService = yield* CLIConfigService;
275
+
276
+ if (hasHelpFlag) {
277
+ yield* cliConfigService.showHelp();
278
+ return;
279
+ }
280
+
281
+ if (hasVersionFlag) {
282
+ yield* cliConfigService.showVersion();
283
+ return;
284
+ }
285
+
286
+ if (hasMissingArgs) {
287
+ yield* Console.error(
288
+ 'āŒ CLI Error: Missing required arguments. Usage: redistail <pubsub|stream> <topic-name>',
289
+ );
290
+ yield* Console.error('šŸ’” Use --help for usage information');
291
+ return yield* Effect.sync(() => process.exit(1));
292
+ }
293
+ });
294
+
295
+ // Use minimal layer without Redis connections for these cases
296
+ return Effect.provide(simpleProgram, CLIConfigServiceLive) as Effect.Effect<
297
+ void,
298
+ never,
299
+ never
300
+ >;
301
+ }
302
+
303
+ // For actual monitoring, determine connection type and use appropriate layer
304
+ const connectionType = args[0];
305
+
306
+ // Create the program with error handling
307
+ const programWithErrorHandling = pipe(
308
+ redistailProgram(args),
309
+ // Handle all possible errors gracefully
310
+ Effect.catchAll((error: unknown) =>
311
+ Effect.gen(function* () {
312
+ // Determine error type and provide appropriate message
313
+ if (typeof error === 'object' && error !== null && '_tag' in error) {
314
+ const taggedError = error as { _tag: string; message?: string };
315
+
316
+ switch (taggedError._tag) {
317
+ case 'CLIError':
318
+ yield* Console.error(
319
+ `āŒ CLI Error: ${taggedError.message || String(error)}`,
320
+ );
321
+ yield* Console.error('šŸ’” Use --help for usage information');
322
+ break;
323
+ case 'ConfigError':
324
+ yield* Console.error(
325
+ `āŒ Configuration Error: ${taggedError.message || String(error)}`,
326
+ );
327
+ yield* Console.error(
328
+ 'šŸ’” Check environment variables and Redis connection settings',
329
+ );
330
+ break;
331
+ case 'MonitoringError':
332
+ yield* Console.error(
333
+ `āŒ Monitoring Error: ${taggedError.message || String(error)}`,
334
+ );
335
+ yield* Console.error(
336
+ 'šŸ’” Check Redis server availability and topic names',
337
+ );
338
+ break;
339
+ default:
340
+ yield* Console.error(`āŒ Unexpected Error: ${String(error)}`);
341
+ }
342
+ } else {
343
+ yield* Console.error(`āŒ Error: ${String(error)}`);
344
+ }
345
+
346
+ // Exit with error code
347
+ return yield* Effect.sync(() => process.exit(1));
348
+ }),
349
+ ),
350
+ );
351
+
352
+ // Select and provide the appropriate layer based on connection type
353
+ if (connectionType === 'pubsub') {
354
+ return Effect.provide(
355
+ programWithErrorHandling,
356
+ PubSubAppLive,
357
+ ) as Effect.Effect<void, never, never>;
358
+ }
359
+ if (connectionType === 'stream') {
360
+ return Effect.provide(
361
+ programWithErrorHandling,
362
+ StreamAppLive,
363
+ ) as Effect.Effect<void, never, never>;
364
+ }
365
+
366
+ // Invalid connection type - show error without Redis connections
367
+ const errorProgram = Effect.gen(function* () {
368
+ yield* Console.error(
369
+ `āŒ CLI Error: Invalid connection type '${connectionType}'. Must be 'pubsub' or 'stream'`,
370
+ );
371
+ yield* Console.error('šŸ’” Use --help for usage information');
372
+ return yield* Effect.sync(() => process.exit(1));
373
+ });
374
+
375
+ return Effect.provide(errorProgram, CLIConfigServiceLive) as Effect.Effect<
376
+ void,
377
+ never,
378
+ never
379
+ >;
380
+ };
381
+
382
+ // ============================================================================
383
+ // Binary Entry Point
384
+ // ============================================================================
385
+
386
+ /**
387
+ * Entry point for the CLI binary
388
+ *
389
+ * This is the main entry point when redistail is executed as a CLI command.
390
+ * It processes command line arguments and runs the main program.
391
+ */
392
+ if (import.meta.main) {
393
+ // Get command line arguments (skip node and script name)
394
+ const args = process.argv.slice(2);
395
+
396
+ // Run the main program with Bun runtime
397
+ BunRuntime.runMain(cliMain(args));
398
+ }
399
+
400
+ // ============================================================================
401
+ // Library Exports
402
+ // ============================================================================
403
+
404
+ /**
405
+ * Re-export commonly used types and services for convenience
406
+ */
407
+ export {
408
+ AppLive,
409
+ CLIConfigService,
410
+ DisplayServiceTag,
411
+ PubSubAppLive,
412
+ PubSubMonitorService,
413
+ SignalHandlerService,
414
+ StreamAppLive,
415
+ StreamMonitorService,
416
+ } from './layers.js';
417
+ export type { CLIConfig, RedistailConfig, RedistailError } from './types.js';
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Signal handler service for graceful shutdown of the redistail CLI utility.
3
+ *
4
+ * This module provides signal handling for SIGINT (Ctrl+C) and SIGTERM signals,
5
+ * ensuring proper cleanup and graceful shutdown for both PubSub and Stream monitoring.
6
+ */
7
+
8
+ import { Context, Effect, Layer, Console } from 'effect';
9
+
10
+ // ============================================================================
11
+ // Service Interface
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Service interface for handling process signals and graceful shutdown
16
+ */
17
+ export interface SignalHandlerService {
18
+ /**
19
+ * Set up signal handlers for SIGINT and SIGTERM
20
+ */
21
+ readonly setupSignalHandlers: () => Effect.Effect<void>;
22
+
23
+ /**
24
+ * Perform graceful shutdown with cleanup
25
+ */
26
+ readonly gracefulShutdown: () => Effect.Effect<void>;
27
+
28
+ /**
29
+ * Check if shutdown has been requested
30
+ */
31
+ readonly isShutdownRequested: () => Effect.Effect<boolean>;
32
+ }
33
+
34
+ /**
35
+ * Service tag for dependency injection
36
+ */
37
+ export const SignalHandlerService = Context.GenericTag<SignalHandlerService>(
38
+ 'SignalHandlerService',
39
+ );
40
+
41
+ // ============================================================================
42
+ // Service Implementation
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Internal state to track shutdown requests
47
+ */
48
+ interface SignalHandlerState {
49
+ shutdownInProgress: boolean;
50
+ }
51
+
52
+ /**
53
+ * Create the signal handler service implementation
54
+ */
55
+ const createSignalHandler = (): SignalHandlerService => {
56
+ // Mutable state for tracking shutdown status
57
+ const state: SignalHandlerState = {
58
+ shutdownInProgress: false,
59
+ };
60
+
61
+ /**
62
+ * Perform graceful shutdown with cleanup
63
+ */
64
+ const gracefulShutdown = (): Effect.Effect<void> =>
65
+ Effect.gen(function* () {
66
+ // Prevent multiple shutdown attempts
67
+ if (state.shutdownInProgress) {
68
+ return;
69
+ }
70
+
71
+ state.shutdownInProgress = true;
72
+
73
+ // Log shutdown message
74
+ yield* Console.error('šŸ›‘ Shutting down redistail...');
75
+
76
+ // Give a brief moment for any pending operations to complete
77
+ yield* Effect.sleep('100 millis');
78
+
79
+ // Log completion and exit
80
+ yield* Console.error('āœ… Shutdown complete');
81
+ return yield* Effect.sync(() => process.exit(0));
82
+ });
83
+
84
+ /**
85
+ * Handle shutdown signal with proper cleanup
86
+ */
87
+ const handleShutdownSignal = (signal: string): Effect.Effect<void> =>
88
+ Effect.gen(function* () {
89
+ // Prevent multiple shutdown attempts
90
+ if (state.shutdownInProgress) {
91
+ return;
92
+ }
93
+
94
+ state.shutdownInProgress = true;
95
+
96
+ // Log shutdown message to stderr
97
+ yield* Console.error(
98
+ `\nšŸ›‘ Received ${signal}, shutting down gracefully...`,
99
+ );
100
+
101
+ // Perform graceful shutdown
102
+ yield* gracefulShutdown();
103
+ });
104
+
105
+ /**
106
+ * Set up signal handlers for SIGINT and SIGTERM
107
+ */
108
+ const setupSignalHandlers = (): Effect.Effect<void> =>
109
+ Effect.sync(() => {
110
+ // Handle SIGINT (Ctrl+C)
111
+ process.on('SIGINT', () => {
112
+ Effect.runSync(handleShutdownSignal('SIGINT'));
113
+ });
114
+
115
+ // Handle SIGTERM (termination request)
116
+ process.on('SIGTERM', () => {
117
+ Effect.runSync(handleShutdownSignal('SIGTERM'));
118
+ });
119
+
120
+ // Handle uncaught exceptions gracefully
121
+ process.on('uncaughtException', (error) => {
122
+ Effect.runSync(
123
+ Effect.gen(function* () {
124
+ yield* Console.error(`āŒ Uncaught exception: ${error.message}`);
125
+ yield* gracefulShutdown();
126
+ }),
127
+ );
128
+ });
129
+
130
+ // Handle unhandled promise rejections
131
+ process.on('unhandledRejection', (reason) => {
132
+ Effect.runSync(
133
+ Effect.gen(function* () {
134
+ yield* Console.error(`āŒ Unhandled rejection: ${reason}`);
135
+ yield* gracefulShutdown();
136
+ }),
137
+ );
138
+ });
139
+ });
140
+
141
+ /**
142
+ * Check if shutdown has been requested
143
+ */
144
+ const isShutdownRequested = (): Effect.Effect<boolean> =>
145
+ Effect.sync(() => state.shutdownInProgress);
146
+
147
+ return {
148
+ setupSignalHandlers,
149
+ gracefulShutdown,
150
+ isShutdownRequested,
151
+ };
152
+ };
153
+
154
+ // ============================================================================
155
+ // Service Layer
156
+ // ============================================================================
157
+
158
+ /**
159
+ * Live implementation layer for the SignalHandlerService
160
+ */
161
+ export const SignalHandlerServiceLive = Layer.succeed(
162
+ SignalHandlerService,
163
+ createSignalHandler(),
164
+ );
165
+
166
+ // ============================================================================
167
+ // Utility Functions
168
+ // ============================================================================
169
+
170
+ /**
171
+ * Effect that sets up signal handlers and runs an effect
172
+ */
173
+ export const withSignalHandling = <A, E, R>(
174
+ effect: Effect.Effect<A, E, R>,
175
+ ): Effect.Effect<A, E, R | SignalHandlerService> =>
176
+ Effect.gen(function* () {
177
+ const signalHandler = yield* SignalHandlerService;
178
+
179
+ // Set up signal handlers
180
+ yield* signalHandler.setupSignalHandlers();
181
+
182
+ // Run the effect
183
+ return yield* effect;
184
+ });
185
+
186
+ /**
187
+ * Check if shutdown has been requested
188
+ */
189
+ export const checkShutdown = (): Effect.Effect<
190
+ boolean,
191
+ never,
192
+ SignalHandlerService
193
+ > =>
194
+ Effect.gen(function* () {
195
+ const signalHandler = yield* SignalHandlerService;
196
+ return yield* signalHandler.isShutdownRequested();
197
+ });
198
+
199
+ /**
200
+ * Perform graceful shutdown
201
+ */
202
+ export const performShutdown = (): Effect.Effect<
203
+ void,
204
+ never,
205
+ SignalHandlerService
206
+ > =>
207
+ Effect.gen(function* () {
208
+ const signalHandler = yield* SignalHandlerService;
209
+ yield* signalHandler.gracefulShutdown();
210
+ });