@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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Display service for formatting and outputting Redis messages.
3
+ *
4
+ * This service handles message formatting for both PubSub and Stream messages,
5
+ * including timestamp formatting, color support, JSON pretty-printing,
6
+ * safe handling of binary content, and proper output stream separation.
7
+ */
8
+
9
+ import { Context, Effect, Console, Layer } from 'effect';
10
+ import type { PubSubMessage, StreamMessage, RedistailError } from './types.js';
11
+ import { RedistailConfig } from './types.js';
12
+
13
+ // ============================================================================
14
+ // Service Interface
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Service interface for message display and formatting
19
+ */
20
+ export interface DisplayService {
21
+ readonly formatPubSubMessage: (
22
+ message: PubSubMessage,
23
+ ) => Effect.Effect<string, never>;
24
+ readonly formatStreamMessage: (
25
+ message: StreamMessage,
26
+ ) => Effect.Effect<string, never>;
27
+ readonly formatError: (error: RedistailError) => Effect.Effect<string, never>;
28
+ readonly outputMessage: (formatted: string) => Effect.Effect<void, never>;
29
+ readonly outputError: (error: string) => Effect.Effect<void, never>;
30
+ }
31
+
32
+ /**
33
+ * Service tag for dependency injection
34
+ */
35
+ export const DisplayServiceTag = Context.GenericTag<DisplayService>(
36
+ '@redistail/DisplayService',
37
+ );
38
+
39
+ // ============================================================================
40
+ // Color Constants
41
+ // ============================================================================
42
+
43
+ /**
44
+ * ANSI color codes for terminal output
45
+ */
46
+ const COLORS = {
47
+ RESET: '\x1b[0m',
48
+ TIMESTAMP: '\x1b[90m', // Bright black (gray)
49
+ PUBSUB_CHANNEL: '\x1b[36m', // Cyan
50
+ STREAM_INFO: '\x1b[35m', // Magenta
51
+ FIELD_KEY: '\x1b[33m', // Yellow
52
+ CONTENT: '\x1b[37m', // White
53
+ ERROR: '\x1b[31m', // Red
54
+ } as const;
55
+
56
+ // ============================================================================
57
+ // Utility Functions
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Format a Date object as an ISO-like timestamp for display
62
+ */
63
+ const formatTimestamp = (date: Date): string =>
64
+ date.toISOString().replace('T', ' ').replace('Z', '');
65
+
66
+ /**
67
+ * Attempt to pretty-print JSON content if valid, otherwise return as-is
68
+ */
69
+ const tryPrettyPrintJson = (content: string, prettyJson: boolean): string => {
70
+ if (!prettyJson) return content;
71
+
72
+ try {
73
+ const parsed = JSON.parse(content);
74
+ return JSON.stringify(parsed, null, 2);
75
+ } catch {
76
+ // Not valid JSON, return original content
77
+ return content;
78
+ }
79
+ };
80
+
81
+ /**
82
+ * Escape non-printable characters in content for safe display
83
+ */
84
+ const escapeNonPrintable = (content: string): string => {
85
+ // Create regex for non-printable characters using String.fromCharCode to avoid linter issues
86
+ const chars = [];
87
+ // Add control characters 0x00-0x1F
88
+ for (let i = 0; i <= 0x1f; i++) {
89
+ chars.push(String.fromCharCode(i));
90
+ }
91
+ // Add DEL and extended control characters 0x7F-0x9F
92
+ for (let i = 0x7f; i <= 0x9f; i++) {
93
+ chars.push(String.fromCharCode(i));
94
+ }
95
+ const nonPrintableRegex = new RegExp(`[${chars.join('')}]`, 'g');
96
+
97
+ return content.replace(nonPrintableRegex, (char) => {
98
+ const code = char.charCodeAt(0);
99
+ return `\\x${code.toString(16).padStart(2, '0')}`;
100
+ });
101
+ };
102
+
103
+ /**
104
+ * Apply color to text if colors are enabled
105
+ */
106
+ const colorize = (text: string, color: string, enabled: boolean): string =>
107
+ enabled ? `${color}${text}${COLORS.RESET}` : text;
108
+
109
+ /**
110
+ * Check if content appears to be binary (contains null bytes or high percentage of non-printable chars)
111
+ */
112
+ const isBinaryContent = (content: string): boolean => {
113
+ // Check for null bytes (common in binary data)
114
+ if (content.includes('\0')) return true;
115
+
116
+ // Create regex for non-printable characters using String.fromCharCode to avoid linter issues
117
+ const chars = [];
118
+ // Add control characters 0x00-0x1F
119
+ for (let i = 0; i <= 0x1f; i++) {
120
+ chars.push(String.fromCharCode(i));
121
+ }
122
+ // Add DEL and extended control characters 0x7F-0x9F
123
+ for (let i = 0x7f; i <= 0x9f; i++) {
124
+ chars.push(String.fromCharCode(i));
125
+ }
126
+ const nonPrintableRegex = new RegExp(`[${chars.join('')}]`, 'g');
127
+
128
+ const nonPrintableCount = (content.match(nonPrintableRegex) || []).length;
129
+ const nonPrintableRatio = nonPrintableCount / content.length;
130
+
131
+ // Consider binary if more than 10% non-printable characters
132
+ return nonPrintableRatio > 0.1;
133
+ };
134
+
135
+ /**
136
+ * Format content safely, handling JSON, binary data, and non-printable characters
137
+ */
138
+ const formatContent = (
139
+ content: string,
140
+ prettyJson: boolean,
141
+ colors: boolean,
142
+ ): string => {
143
+ // First, check if content is binary and needs escaping
144
+ const safeContent = isBinaryContent(content)
145
+ ? escapeNonPrintable(content)
146
+ : content;
147
+
148
+ // Try to pretty-print JSON if enabled
149
+ const formattedContent = tryPrettyPrintJson(safeContent, prettyJson);
150
+
151
+ // Apply color if enabled
152
+ return colorize(formattedContent, COLORS.CONTENT, colors);
153
+ };
154
+
155
+ // ============================================================================
156
+ // Service Implementation
157
+ // ============================================================================
158
+
159
+ /**
160
+ * Create a DisplayService implementation with the given configuration
161
+ */
162
+ const createDisplayService = (config: RedistailConfig): DisplayService => ({
163
+ formatPubSubMessage: (message: PubSubMessage) =>
164
+ Effect.sync(() => {
165
+ const parts: string[] = [];
166
+
167
+ // Add timestamp if enabled
168
+ if (config.display.timestamps) {
169
+ const timestamp = formatTimestamp(message.timestamp);
170
+ parts.push(
171
+ colorize(timestamp, COLORS.TIMESTAMP, config.display.colors),
172
+ );
173
+ }
174
+
175
+ // Add channel name in brackets
176
+ const channel = `[${message.channel}]`;
177
+ parts.push(
178
+ colorize(channel, COLORS.PUBSUB_CHANNEL, config.display.colors),
179
+ );
180
+
181
+ // Add formatted content
182
+ const content = formatContent(
183
+ message.content,
184
+ config.display.prettyJson,
185
+ config.display.colors,
186
+ );
187
+ parts.push(content);
188
+
189
+ return parts.join(' ');
190
+ }),
191
+
192
+ formatStreamMessage: (message: StreamMessage) =>
193
+ Effect.sync(() => {
194
+ const parts: string[] = [];
195
+
196
+ // Add timestamp if enabled
197
+ if (config.display.timestamps) {
198
+ const timestamp = formatTimestamp(message.timestamp);
199
+ parts.push(
200
+ colorize(timestamp, COLORS.TIMESTAMP, config.display.colors),
201
+ );
202
+ }
203
+
204
+ // Add stream key and entry ID in brackets
205
+ const streamInfo = `[${message.streamKey}:${message.entryId}]`;
206
+ parts.push(
207
+ colorize(streamInfo, COLORS.STREAM_INFO, config.display.colors),
208
+ );
209
+
210
+ // Format fields as key=value pairs
211
+ const fieldPairs = Object.entries(message.fields).map(([key, value]) => {
212
+ const coloredKey = colorize(
213
+ key,
214
+ COLORS.FIELD_KEY,
215
+ config.display.colors,
216
+ );
217
+ const formattedValue = formatContent(
218
+ value,
219
+ config.display.prettyJson,
220
+ config.display.colors,
221
+ );
222
+ return `${coloredKey}=${formattedValue}`;
223
+ });
224
+
225
+ if (fieldPairs.length > 0) {
226
+ parts.push(fieldPairs.join(' '));
227
+ }
228
+
229
+ return parts.join(' ');
230
+ }),
231
+
232
+ formatError: (error: RedistailError) =>
233
+ Effect.sync(() => {
234
+ const errorMessage = `ERROR: ${error.message}`;
235
+ return colorize(errorMessage, COLORS.ERROR, config.display.colors);
236
+ }),
237
+
238
+ outputMessage: (formatted: string) => Console.log(formatted), // Messages go to stdout
239
+
240
+ outputError: (error: string) => Console.error(error), // Errors go to stderr
241
+ });
242
+
243
+ // ============================================================================
244
+ // Service Layer
245
+ // ============================================================================
246
+
247
+ /**
248
+ * Create a DisplayService layer with the given configuration
249
+ */
250
+ export const makeDisplayServiceLayer = (
251
+ config: RedistailConfig,
252
+ ): Layer.Layer<DisplayService> =>
253
+ Layer.succeed(DisplayServiceTag, createDisplayService(config));
254
+
255
+ /**
256
+ * Live layer that provides DisplayService implementation
257
+ * This is a convenience layer that uses default configuration
258
+ */
259
+ export const DisplayServiceLive: Layer.Layer<DisplayService> = Layer.effect(
260
+ DisplayServiceTag,
261
+ Effect.sync(() => {
262
+ // Use default configuration for the live layer
263
+ // In practice, this would be provided by the application layer
264
+ const defaultConfig = RedistailConfig({
265
+ redis: {
266
+ host: '127.0.0.1',
267
+ port: 6379,
268
+ timeout: 5000,
269
+ retryAttempts: 3,
270
+ retryDelay: 1000,
271
+ },
272
+ display: {
273
+ colors: true,
274
+ timestamps: true,
275
+ prettyJson: true,
276
+ },
277
+ monitoring: {
278
+ blockTimeout: 5000,
279
+ maxReconnectAttempts: 5,
280
+ },
281
+ });
282
+ return createDisplayService(defaultConfig);
283
+ }),
284
+ );
285
+
286
+ // ============================================================================
287
+ // Utility Functions for Testing
288
+ // ============================================================================
289
+
290
+ /**
291
+ * Create a DisplayService for testing with custom configuration
292
+ */
293
+ export const createTestDisplayService = (
294
+ config: RedistailConfig,
295
+ ): DisplayService => createDisplayService(config);
296
+
297
+ /**
298
+ * Helper to strip ANSI color codes from formatted output for testing
299
+ */
300
+ export const stripAnsiColors = (text: string): string => {
301
+ // Create regex for ANSI escape sequences using String.fromCharCode to avoid linter issues
302
+ const esc = String.fromCharCode(0x1b); // ESC character
303
+ const ansiRegex = new RegExp(`${esc}\\[[0-9;]*m`, 'g');
304
+ return text.replace(ansiRegex, '');
305
+ };
306
+
307
+ /**
308
+ * Helper to check if text contains ANSI color codes
309
+ */
310
+ export const hasAnsiColors = (text: string): boolean => {
311
+ // Create regex for ANSI escape sequences using String.fromCharCode to avoid linter issues
312
+ const esc = String.fromCharCode(0x1b); // ESC character
313
+ const ansiRegex = new RegExp(`${esc}\\[[0-9;]*m`);
314
+ return ansiRegex.test(text);
315
+ };
316
+
317
+ // ============================================================================
318
+ // Additional Exports
319
+ // ============================================================================
320
+
321
+ // All exports are already defined above