@ceschiatti/redistail 0.0.2 → 0.0.4
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/README.md +680 -0
- package/dist/cli/redistail.exe +0 -0
- package/dist/cli/redistail.js +5 -5
- package/package.json +38 -37
- package/src/cli/config-service.ts +455 -0
- package/src/cli/display-service.ts +321 -0
- package/src/cli/layers.ts +492 -0
- package/src/cli/pubsub-monitor-service.ts +296 -0
- package/src/cli/redistail-launcher.js +65 -0
- package/src/cli/redistail.ts +417 -0
- package/src/cli/signal-handler-service.ts +210 -0
- package/src/cli/stream-monitor-service.ts +427 -0
- package/src/cli/types.ts +302 -0
- package/src/cli/version-generated.ts +27 -0
- package/src/cli/version.ts +27 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PubSub monitoring service for the redistail CLI utility.
|
|
3
|
+
*
|
|
4
|
+
* This service provides Redis Pub/Sub channel monitoring capabilities with
|
|
5
|
+
* automatic reconnection, exponential backoff, and proper error handling.
|
|
6
|
+
* It integrates with the effect-redis RedisPubSub service and follows
|
|
7
|
+
* Effect-TS patterns for dependency injection and resource management.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
Context,
|
|
12
|
+
Effect,
|
|
13
|
+
Layer,
|
|
14
|
+
Schedule,
|
|
15
|
+
Stream,
|
|
16
|
+
pipe,
|
|
17
|
+
Duration,
|
|
18
|
+
} from 'effect';
|
|
19
|
+
import { RedisPubSub } from 'effect-redis';
|
|
20
|
+
import {
|
|
21
|
+
type PubSubMessage,
|
|
22
|
+
type RedistailConfig,
|
|
23
|
+
type MonitoringError,
|
|
24
|
+
type ConfigError,
|
|
25
|
+
PubSubMessage as PubSubMessageConstructor,
|
|
26
|
+
createConnectionLostError,
|
|
27
|
+
createSubscriptionFailedError,
|
|
28
|
+
} from './types';
|
|
29
|
+
import { CLIConfigService } from './config-service';
|
|
30
|
+
import type { RedisError } from 'effect-redis';
|
|
31
|
+
|
|
32
|
+
// Get the service type from the Context.Tag
|
|
33
|
+
type RedisPubSubService = Context.Tag.Service<RedisPubSub>;
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Service Interface
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Service interface for PubSub monitoring operations
|
|
41
|
+
*/
|
|
42
|
+
export interface PubSubMonitorService {
|
|
43
|
+
/**
|
|
44
|
+
* Monitor a Redis Pub/Sub channel with automatic reconnection
|
|
45
|
+
* @param channel - The channel name to monitor
|
|
46
|
+
* @returns Stream of PubSub messages with error handling and reconnection
|
|
47
|
+
*/
|
|
48
|
+
readonly monitor: (
|
|
49
|
+
channel: string,
|
|
50
|
+
) => Stream.Stream<PubSubMessage, MonitoringError>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Subscribe to a Redis Pub/Sub channel
|
|
54
|
+
* @param channel - The channel name to subscribe to
|
|
55
|
+
* @returns Effect that yields a stream of PubSub messages
|
|
56
|
+
*/
|
|
57
|
+
readonly subscribe: (
|
|
58
|
+
channel: string,
|
|
59
|
+
) => Effect.Effect<
|
|
60
|
+
Stream.Stream<PubSubMessage, MonitoringError>,
|
|
61
|
+
MonitoringError
|
|
62
|
+
>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Context tag for PubSub monitoring service dependency injection
|
|
67
|
+
*/
|
|
68
|
+
export const PubSubMonitorService = Context.GenericTag<PubSubMonitorService>(
|
|
69
|
+
'PubSubMonitorService',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Helper Functions
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Convert RedisError to MonitoringError
|
|
78
|
+
*/
|
|
79
|
+
const convertRedisError = (
|
|
80
|
+
error: RedisError,
|
|
81
|
+
context: string,
|
|
82
|
+
): MonitoringError => {
|
|
83
|
+
return createSubscriptionFailedError(
|
|
84
|
+
`${context}: ${error.message}`,
|
|
85
|
+
true,
|
|
86
|
+
error,
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Service Implementation
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Creates a PubSub monitor service implementation
|
|
96
|
+
*/
|
|
97
|
+
const createPubSubMonitorService = (
|
|
98
|
+
redisPubSubService: RedisPubSubService,
|
|
99
|
+
config: RedistailConfig,
|
|
100
|
+
): PubSubMonitorService => ({
|
|
101
|
+
monitor: (channel: string) =>
|
|
102
|
+
pipe(
|
|
103
|
+
// Create the base subscription stream
|
|
104
|
+
Effect.mapError(
|
|
105
|
+
redisPubSubService.subscribe(channel),
|
|
106
|
+
(error: RedisError) =>
|
|
107
|
+
convertRedisError(
|
|
108
|
+
error,
|
|
109
|
+
`Failed to subscribe to channel '${channel}'`,
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
// Convert Effect to Stream
|
|
113
|
+
Stream.unwrap,
|
|
114
|
+
// Transform Redis messages to PubSubMessage format
|
|
115
|
+
Stream.map((content: string) =>
|
|
116
|
+
PubSubMessageConstructor({
|
|
117
|
+
timestamp: new Date(),
|
|
118
|
+
channel,
|
|
119
|
+
content,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
// Handle stream errors and convert to MonitoringError
|
|
123
|
+
Stream.mapError((error: Error) =>
|
|
124
|
+
createConnectionLostError(
|
|
125
|
+
`Connection lost while monitoring channel '${channel}': ${error.message}`,
|
|
126
|
+
error,
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
// Add automatic reconnection with exponential backoff
|
|
130
|
+
Stream.retry(
|
|
131
|
+
pipe(
|
|
132
|
+
Schedule.exponential(Duration.seconds(1)),
|
|
133
|
+
Schedule.intersect(
|
|
134
|
+
Schedule.recurs(config.monitoring.maxReconnectAttempts),
|
|
135
|
+
),
|
|
136
|
+
Schedule.tapInput((error: MonitoringError) =>
|
|
137
|
+
Effect.logWarning(
|
|
138
|
+
`PubSub monitoring error for channel '${channel}': ${error.message}. Retrying...`,
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
// Handle final failure after max retries
|
|
144
|
+
Stream.catchAll((error: MonitoringError) =>
|
|
145
|
+
Stream.fail(
|
|
146
|
+
createSubscriptionFailedError(
|
|
147
|
+
`Failed to monitor channel '${channel}' after ${config.monitoring.maxReconnectAttempts} attempts: ${error.message}`,
|
|
148
|
+
false, // Not retryable after max attempts
|
|
149
|
+
error,
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
|
|
155
|
+
subscribe: (channel: string) =>
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
// Attempt to subscribe to the channel
|
|
158
|
+
const messageStream = yield* Effect.mapError(
|
|
159
|
+
redisPubSubService.subscribe(channel),
|
|
160
|
+
(error: RedisError) =>
|
|
161
|
+
convertRedisError(
|
|
162
|
+
error,
|
|
163
|
+
`Failed to subscribe to channel '${channel}'`,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Transform the stream to PubSubMessage format
|
|
168
|
+
return pipe(
|
|
169
|
+
messageStream,
|
|
170
|
+
Stream.map((content: string) =>
|
|
171
|
+
PubSubMessageConstructor({
|
|
172
|
+
timestamp: new Date(),
|
|
173
|
+
channel,
|
|
174
|
+
content,
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
// Handle stream errors
|
|
178
|
+
Stream.mapError((error: Error) =>
|
|
179
|
+
createConnectionLostError(
|
|
180
|
+
`Stream error for channel '${channel}': ${error.message}`,
|
|
181
|
+
error,
|
|
182
|
+
),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Live Layer Implementation
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Live layer for PubSub monitoring service
|
|
194
|
+
* Depends on RedisPubSub and CLIConfigService services
|
|
195
|
+
*/
|
|
196
|
+
export const PubSubMonitorServiceLive: Layer.Layer<
|
|
197
|
+
PubSubMonitorService,
|
|
198
|
+
ConfigError,
|
|
199
|
+
RedisPubSub | CLIConfigService
|
|
200
|
+
> = Layer.effect(
|
|
201
|
+
PubSubMonitorService,
|
|
202
|
+
Effect.gen(function* () {
|
|
203
|
+
const redisPubSubService = yield* RedisPubSub;
|
|
204
|
+
const configService = yield* CLIConfigService;
|
|
205
|
+
const config = yield* configService.loadConfig();
|
|
206
|
+
return createPubSubMonitorService(redisPubSubService, config);
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// Utility Functions
|
|
212
|
+
// ============================================================================
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Helper function to create a monitoring stream with custom retry configuration
|
|
216
|
+
*/
|
|
217
|
+
export const createMonitoringStreamWithRetry = (
|
|
218
|
+
channel: string,
|
|
219
|
+
redisPubSubService: RedisPubSubService,
|
|
220
|
+
maxRetries: number,
|
|
221
|
+
baseDelaySeconds = 1,
|
|
222
|
+
): Stream.Stream<PubSubMessage, MonitoringError> =>
|
|
223
|
+
pipe(
|
|
224
|
+
Effect.mapError(
|
|
225
|
+
redisPubSubService.subscribe(channel),
|
|
226
|
+
(error: RedisError) =>
|
|
227
|
+
convertRedisError(error, `Failed to subscribe to channel '${channel}'`),
|
|
228
|
+
),
|
|
229
|
+
Stream.unwrap,
|
|
230
|
+
Stream.map((content: string) =>
|
|
231
|
+
PubSubMessageConstructor({
|
|
232
|
+
timestamp: new Date(),
|
|
233
|
+
channel,
|
|
234
|
+
content,
|
|
235
|
+
}),
|
|
236
|
+
),
|
|
237
|
+
Stream.mapError((error: Error) =>
|
|
238
|
+
createConnectionLostError(
|
|
239
|
+
`Connection lost while monitoring channel '${channel}': ${error.message}`,
|
|
240
|
+
error,
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
Stream.retry(
|
|
244
|
+
pipe(
|
|
245
|
+
Schedule.exponential(Duration.seconds(baseDelaySeconds)),
|
|
246
|
+
Schedule.intersect(Schedule.recurs(maxRetries)),
|
|
247
|
+
Schedule.tapInput((error: MonitoringError) =>
|
|
248
|
+
Effect.logWarning(
|
|
249
|
+
`PubSub monitoring error for channel '${channel}': ${error.message}. Retrying...`,
|
|
250
|
+
),
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Helper function to validate channel names
|
|
258
|
+
*/
|
|
259
|
+
export const validateChannelName = (
|
|
260
|
+
channel: string,
|
|
261
|
+
): Effect.Effect<string, MonitoringError> =>
|
|
262
|
+
Effect.gen(function* () {
|
|
263
|
+
if (!channel || typeof channel !== 'string') {
|
|
264
|
+
return yield* Effect.fail(
|
|
265
|
+
createSubscriptionFailedError(
|
|
266
|
+
'Channel name must be a non-empty string',
|
|
267
|
+
false,
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const trimmedChannel = channel.trim();
|
|
273
|
+
if (trimmedChannel.length === 0) {
|
|
274
|
+
return yield* Effect.fail(
|
|
275
|
+
createSubscriptionFailedError(
|
|
276
|
+
'Channel name cannot be empty or whitespace only',
|
|
277
|
+
false,
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return trimmedChannel;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Helper function to create a monitored channel with validation
|
|
287
|
+
*/
|
|
288
|
+
export const monitorChannelWithValidation = (
|
|
289
|
+
channel: string,
|
|
290
|
+
pubSubService: PubSubMonitorService,
|
|
291
|
+
): Stream.Stream<PubSubMessage, MonitoringError> =>
|
|
292
|
+
pipe(
|
|
293
|
+
validateChannelName(channel),
|
|
294
|
+
Effect.map((validChannel) => pubSubService.monitor(validChannel)),
|
|
295
|
+
Stream.unwrap,
|
|
296
|
+
);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-platform launcher for redistail CLI
|
|
5
|
+
* Detects platform and runs the appropriate binary
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = dirname(__filename);
|
|
15
|
+
|
|
16
|
+
function launchRedistail() {
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
// Try to use the compiled executable first (better performance)
|
|
20
|
+
const exePath = join(__dirname, 'redistail.exe');
|
|
21
|
+
const jsPath = join(__dirname, 'redistail.js');
|
|
22
|
+
|
|
23
|
+
let command;
|
|
24
|
+
let commandArgs;
|
|
25
|
+
|
|
26
|
+
if (process.platform === 'win32' && existsSync(exePath)) {
|
|
27
|
+
// Use the compiled executable on Windows
|
|
28
|
+
command = exePath;
|
|
29
|
+
commandArgs = args;
|
|
30
|
+
} else if (existsSync(jsPath)) {
|
|
31
|
+
// Fall back to Node.js execution
|
|
32
|
+
command = 'node';
|
|
33
|
+
commandArgs = [jsPath, ...args];
|
|
34
|
+
} else {
|
|
35
|
+
console.error('Error: redistail binary not found');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Spawn the process and pipe stdio
|
|
40
|
+
const child = spawn(command, commandArgs, {
|
|
41
|
+
stdio: 'inherit',
|
|
42
|
+
windowsHide: false,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Handle process exit
|
|
46
|
+
child.on('close', (code) => {
|
|
47
|
+
process.exit(code || 0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
child.on('error', (error) => {
|
|
51
|
+
console.error('Error launching redistail:', error.message);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Handle process signals
|
|
56
|
+
process.on('SIGINT', () => {
|
|
57
|
+
child.kill('SIGINT');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
process.on('SIGTERM', () => {
|
|
61
|
+
child.kill('SIGTERM');
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
launchRedistail();
|