BREAKING CHANGE(core): Refactor to v3: introduce modular core/domain architecture, plugin system, observability and strict TypeScript configuration; remove legacy classes

This commit is contained in:
2025-11-29 18:32:00 +00:00
parent 53673e37cb
commit 7e89b6ebf5
68 changed files with 17020 additions and 720 deletions

View File

@@ -0,0 +1,257 @@
/**
* Cache Plugin
*
* Caches GET request responses to reduce load on Elasticsearch
*/
import { defaultLogger } from '../../observability/logger.js';
import type { Plugin, PluginContext, PluginResponse, CachePluginConfig } from '../types.js';
/**
* Cache entry
*/
interface CacheEntry<T = unknown> {
response: PluginResponse<T>;
cachedAt: number;
expiresAt: number;
hits: number;
}
/**
* Default configuration
*/
const DEFAULT_CONFIG: Required<CachePluginConfig> = {
enabled: true,
maxEntries: 1000,
defaultTTL: 60, // 60 seconds
keyGenerator: (context: PluginContext) => {
const query = context.request.querystring
? JSON.stringify(context.request.querystring)
: '';
const body = context.request.body ? JSON.stringify(context.request.body) : '';
return `${context.request.method}:${context.request.path}:${query}:${body}`;
},
methods: ['GET'],
};
/**
* Create cache plugin
*/
export function createCachePlugin(config: CachePluginConfig = {}): Plugin {
const pluginConfig: Required<CachePluginConfig> = {
...DEFAULT_CONFIG,
...config,
keyGenerator: config.keyGenerator || DEFAULT_CONFIG.keyGenerator,
};
const logger = defaultLogger;
const cache = new Map<string, CacheEntry>();
let cacheHits = 0;
let cacheMisses = 0;
/**
* Get from cache
*/
function getFromCache<T>(key: string): PluginResponse<T> | null {
const entry = cache.get(key) as CacheEntry<T> | undefined;
if (!entry) {
cacheMisses++;
return null;
}
// Check expiration
const now = Date.now();
if (now >= entry.expiresAt) {
cache.delete(key);
cacheMisses++;
return null;
}
// Update stats
entry.hits++;
cacheHits++;
logger.debug('Cache hit', {
key,
age: now - entry.cachedAt,
hits: entry.hits,
});
return entry.response;
}
/**
* Set in cache
*/
function setInCache<T>(key: string, response: PluginResponse<T>, ttl: number): void {
// Check if cache is full
if (cache.size >= pluginConfig.maxEntries && !cache.has(key)) {
evictOldest();
}
const now = Date.now();
cache.set(key, {
response,
cachedAt: now,
expiresAt: now + ttl * 1000,
hits: 0,
});
logger.debug('Cache set', { key, ttl });
}
/**
* Evict oldest entry
*/
function evictOldest(): void {
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of cache) {
if (entry.cachedAt < oldestTime) {
oldestTime = entry.cachedAt;
oldestKey = key;
}
}
if (oldestKey) {
cache.delete(oldestKey);
logger.debug('Cache evicted', { key: oldestKey });
}
}
/**
* Clear cache
*/
function clearCache(): void {
cache.clear();
cacheHits = 0;
cacheMisses = 0;
logger.info('Cache cleared');
}
/**
* Clean expired entries
*/
function cleanExpired(): void {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of cache) {
if (now >= entry.expiresAt) {
cache.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
logger.debug('Cache cleaned', { expired: cleaned });
}
}
// Periodic cleanup
let cleanupTimer: NodeJS.Timeout;
return {
name: 'cache',
version: '1.0.0',
priority: 50, // Execute in the middle
initialize: () => {
// Start periodic cleanup
cleanupTimer = setInterval(cleanExpired, 60000); // Every minute
logger.info('Cache plugin initialized', {
maxEntries: pluginConfig.maxEntries,
defaultTTL: pluginConfig.defaultTTL,
methods: pluginConfig.methods,
});
},
beforeRequest: <T>(context: PluginContext): PluginContext | null => {
if (!pluginConfig.enabled) {
return context;
}
// Only cache configured methods
if (!pluginConfig.methods.includes(context.request.method)) {
return context;
}
// Generate cache key
const cacheKey = pluginConfig.keyGenerator(context);
// Check cache
const cachedResponse = getFromCache<T>(cacheKey);
if (cachedResponse) {
// Store cached response in shared data for afterResponse to use
context.shared.set('cache_hit', true);
context.shared.set('cached_response', cachedResponse);
context.shared.set('cache_key', cacheKey);
} else {
context.shared.set('cache_hit', false);
context.shared.set('cache_key', cacheKey);
}
return context;
},
afterResponse: <T>(context: PluginContext, response: PluginResponse<T>) => {
if (!pluginConfig.enabled) {
return response;
}
const cacheHit = context.shared.get('cache_hit');
// If it was a cache hit, return the cached response
if (cacheHit) {
return context.shared.get('cached_response') as PluginResponse<T>;
}
// Otherwise, cache this response
const cacheKey = context.shared.get('cache_key') as string;
if (cacheKey && pluginConfig.methods.includes(context.request.method)) {
// Only cache successful responses
if (response.statusCode >= 200 && response.statusCode < 300) {
setInCache(cacheKey, response, pluginConfig.defaultTTL);
}
}
return response;
},
destroy: () => {
if (cleanupTimer) {
clearInterval(cleanupTimer);
}
clearCache();
logger.info('Cache plugin destroyed', {
totalHits: cacheHits,
totalMisses: cacheMisses,
hitRatio: cacheHits / (cacheHits + cacheMisses) || 0,
});
},
};
}
/**
* Get cache statistics
*/
export function getCacheStats(plugin: Plugin): {
size: number;
hits: number;
misses: number;
hitRatio: number;
} | null {
if (plugin.name !== 'cache') {
return null;
}
// This would require exposing stats from the plugin
// For now, return null
return null;
}

View File

@@ -0,0 +1,164 @@
/**
* Logging Plugin
*
* Automatically logs requests, responses, and errors
*/
import { defaultLogger } from '../../observability/logger.js';
import type { Plugin, PluginContext, PluginResponse, LoggingPluginConfig } from '../types.js';
/**
* Default configuration
*/
const DEFAULT_CONFIG: Required<LoggingPluginConfig> = {
logRequests: true,
logResponses: true,
logErrors: true,
logRequestBody: false,
logResponseBody: false,
maxBodySize: 1024, // 1KB
sensitiveFields: ['password', 'token', 'secret', 'authorization', 'api_key'],
};
/**
* Create logging plugin
*/
export function createLoggingPlugin(config: LoggingPluginConfig = {}): Plugin {
const pluginConfig = { ...DEFAULT_CONFIG, ...config };
const logger = defaultLogger;
return {
name: 'logging',
version: '1.0.0',
priority: 10, // Execute early
beforeRequest: (context: PluginContext) => {
if (!pluginConfig.logRequests) {
return context;
}
const logData: Record<string, unknown> = {
requestId: context.request.requestId,
method: context.request.method,
path: context.request.path,
};
// Add querystring if present
if (context.request.querystring) {
logData.querystring = context.request.querystring;
}
// Add request body if enabled
if (pluginConfig.logRequestBody && context.request.body) {
const bodyStr = JSON.stringify(context.request.body);
if (bodyStr.length <= pluginConfig.maxBodySize) {
logData.body = sanitizeObject(context.request.body, pluginConfig.sensitiveFields);
} else {
logData.bodySize = bodyStr.length;
logData.bodyTruncated = true;
}
}
logger.debug('Elasticsearch request', logData);
return context;
},
afterResponse: <T>(context: PluginContext, response: PluginResponse<T>) => {
if (!pluginConfig.logResponses) {
return response;
}
const duration = Date.now() - context.request.startTime;
const logData: Record<string, unknown> = {
requestId: context.request.requestId,
method: context.request.method,
path: context.request.path,
statusCode: response.statusCode,
duration,
};
// Add warnings if present
if (response.warnings && response.warnings.length > 0) {
logData.warnings = response.warnings;
}
// Add response body if enabled
if (pluginConfig.logResponseBody && response.body) {
const bodyStr = JSON.stringify(response.body);
if (bodyStr.length <= pluginConfig.maxBodySize) {
logData.body = response.body;
} else {
logData.bodySize = bodyStr.length;
logData.bodyTruncated = true;
}
}
logger.info('Elasticsearch response', logData);
return response;
},
onError: (context) => {
if (!pluginConfig.logErrors) {
return null;
}
const duration = Date.now() - context.request.startTime;
logger.error('Elasticsearch error', {
requestId: context.request.requestId,
method: context.request.method,
path: context.request.path,
duration,
attempts: context.attempts,
error: {
name: context.error.name,
message: context.error.message,
stack: context.error.stack,
},
statusCode: context.response?.statusCode,
});
// Don't handle error, just log it
return null;
},
};
}
/**
* Sanitize object by removing sensitive fields
*/
function sanitizeObject(obj: unknown, sensitiveFields: string[]): unknown {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => sanitizeObject(item, sensitiveFields));
}
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const lowerKey = key.toLowerCase();
// Check if key is sensitive
const isSensitive = sensitiveFields.some((field) => lowerKey.includes(field.toLowerCase()));
if (isSensitive) {
sanitized[key] = '[REDACTED]';
} else if (typeof value === 'object' && value !== null) {
sanitized[key] = sanitizeObject(value, sensitiveFields);
} else {
sanitized[key] = value;
}
}
return sanitized;
}

View File

@@ -0,0 +1,141 @@
/**
* Metrics Plugin
*
* Automatically collects metrics for requests and responses
*/
import { defaultMetricsCollector } from '../../observability/metrics.js';
import type { Plugin, PluginContext, PluginResponse, MetricsPluginConfig } from '../types.js';
/**
* Default configuration
*/
const DEFAULT_CONFIG: Required<MetricsPluginConfig> = {
enabled: true,
prefix: 'elasticsearch',
recordDuration: true,
recordSize: true,
recordResponseSize: true,
};
/**
* Create metrics plugin
*/
export function createMetricsPlugin(config: MetricsPluginConfig = {}): Plugin {
const pluginConfig = { ...DEFAULT_CONFIG, ...config };
const metrics = defaultMetricsCollector;
return {
name: 'metrics',
version: '1.0.0',
priority: 20, // Execute early, after logging
beforeRequest: (context: PluginContext) => {
if (!pluginConfig.enabled) {
return context;
}
// Record request counter
metrics.recordCounter(`${pluginConfig.prefix}.requests`, 1, {
method: context.request.method,
path: extractIndexFromPath(context.request.path),
});
// Record request size if enabled
if (pluginConfig.recordSize && context.request.body) {
const size = Buffer.byteLength(JSON.stringify(context.request.body), 'utf8');
metrics.recordHistogram(`${pluginConfig.prefix}.request.size`, size, {
method: context.request.method,
});
}
return context;
},
afterResponse: <T>(context: PluginContext, response: PluginResponse<T>) => {
if (!pluginConfig.enabled) {
return response;
}
const duration = Date.now() - context.request.startTime;
// Record request duration if enabled
if (pluginConfig.recordDuration) {
metrics.recordHistogram(`${pluginConfig.prefix}.request.duration`, duration, {
method: context.request.method,
path: extractIndexFromPath(context.request.path),
status: response.statusCode.toString(),
});
}
// Record response size if enabled
if (pluginConfig.recordResponseSize && response.body) {
const size = Buffer.byteLength(JSON.stringify(response.body), 'utf8');
metrics.recordHistogram(`${pluginConfig.prefix}.response.size`, size, {
method: context.request.method,
status: response.statusCode.toString(),
});
}
// Record success/failure
const success = response.statusCode >= 200 && response.statusCode < 300;
metrics.recordCounter(
`${pluginConfig.prefix}.requests.${success ? 'success' : 'failure'}`,
1,
{
method: context.request.method,
status: response.statusCode.toString(),
}
);
return response;
},
onError: (context) => {
if (!pluginConfig.enabled) {
return null;
}
const duration = Date.now() - context.request.startTime;
// Record error
metrics.recordCounter(`${pluginConfig.prefix}.errors`, 1, {
method: context.request.method,
path: extractIndexFromPath(context.request.path),
error: context.error.name,
});
// Record error duration
if (pluginConfig.recordDuration) {
metrics.recordHistogram(`${pluginConfig.prefix}.error.duration`, duration, {
method: context.request.method,
error: context.error.name,
});
}
// Don't handle error
return null;
},
};
}
/**
* Extract index name from path
*/
function extractIndexFromPath(path: string): string {
// Remove leading slash
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
// Split by slash and get first segment
const segments = cleanPath.split('/');
// Common patterns:
// /{index}/_search
// /{index}/_doc/{id}
// /_cat/indices
if (segments[0].startsWith('_')) {
return segments[0]; // API endpoint like _cat, _search
}
return segments[0] || 'unknown';
}

View File

@@ -0,0 +1,166 @@
/**
* Rate Limit Plugin
*
* Limits request rate to prevent overwhelming Elasticsearch
*/
import { defaultLogger } from '../../observability/logger.js';
import type { Plugin, PluginContext, RateLimitPluginConfig } from '../types.js';
/**
* Default configuration
*/
const DEFAULT_CONFIG: Required<RateLimitPluginConfig> = {
maxRequestsPerSecond: 100,
burstSize: 10,
waitForSlot: true,
maxWaitTime: 5000, // 5 seconds
};
/**
* Token bucket for rate limiting
*/
class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(
private maxTokens: number,
private refillRate: number // tokens per second
) {
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
/**
* Try to consume a token
*/
async tryConsume(waitForToken: boolean, maxWaitTime: number): Promise<boolean> {
this.refill();
// If we have tokens available, consume one
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
// If not waiting, reject immediately
if (!waitForToken) {
return false;
}
// Calculate wait time for next token
const waitTime = Math.min((1 / this.refillRate) * 1000, maxWaitTime);
// Wait for token to be available
await new Promise((resolve) => setTimeout(resolve, waitTime));
// Try again after waiting
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
/**
* Refill tokens based on time elapsed
*/
private refill(): void {
const now = Date.now();
const timePassed = (now - this.lastRefill) / 1000; // seconds
const tokensToAdd = timePassed * this.refillRate;
this.tokens = Math.min(this.tokens + tokensToAdd, this.maxTokens);
this.lastRefill = now;
}
/**
* Get current token count
*/
getTokens(): number {
this.refill();
return this.tokens;
}
/**
* Reset bucket
*/
reset(): void {
this.tokens = this.maxTokens;
this.lastRefill = Date.now();
}
}
/**
* Create rate limit plugin
*/
export function createRateLimitPlugin(config: RateLimitPluginConfig = {}): Plugin {
const pluginConfig = { ...DEFAULT_CONFIG, ...config };
const logger = defaultLogger;
let tokenBucket: TokenBucket;
let rejectedRequests = 0;
let delayedRequests = 0;
let totalWaitTime = 0;
return {
name: 'rate-limit',
version: '1.0.0',
priority: 95, // Execute very late, right before request
initialize: () => {
tokenBucket = new TokenBucket(
pluginConfig.burstSize,
pluginConfig.maxRequestsPerSecond
);
logger.info('Rate limit plugin initialized', {
maxRequestsPerSecond: pluginConfig.maxRequestsPerSecond,
burstSize: pluginConfig.burstSize,
waitForSlot: pluginConfig.waitForSlot,
});
},
beforeRequest: async (context: PluginContext) => {
const startTime = Date.now();
// Try to consume a token
const acquired = await tokenBucket.tryConsume(
pluginConfig.waitForSlot,
pluginConfig.maxWaitTime
);
if (!acquired) {
rejectedRequests++;
logger.warn('Request rate limited', {
requestId: context.request.requestId,
rejectedCount: rejectedRequests,
});
// Return null to cancel the request
return null;
}
const waitTime = Date.now() - startTime;
if (waitTime > 100) {
// Only log if we actually waited
delayedRequests++;
totalWaitTime += waitTime;
logger.debug('Request delayed by rate limiter', {
requestId: context.request.requestId,
waitTime,
availableTokens: tokenBucket.getTokens(),
});
}
return context;
},
};
}

View File

@@ -0,0 +1,140 @@
/**
* Retry Plugin
*
* Automatically retries failed requests with exponential backoff
*/
import { defaultLogger } from '../../observability/logger.js';
import type { Plugin, PluginErrorContext, RetryPluginConfig } from '../types.js';
/**
* Default configuration
*/
const DEFAULT_CONFIG: Required<RetryPluginConfig> = {
maxRetries: 3,
initialDelay: 1000, // 1 second
maxDelay: 30000, // 30 seconds
backoffMultiplier: 2,
retryableStatusCodes: [429, 502, 503, 504],
retryableErrors: [
'ECONNRESET',
'ENOTFOUND',
'ESOCKETTIMEDOUT',
'ETIMEDOUT',
'ECONNREFUSED',
'EHOSTUNREACH',
'EPIPE',
'EAI_AGAIN',
],
};
/**
* Create retry plugin
*/
export function createRetryPlugin(config: RetryPluginConfig = {}): Plugin {
const pluginConfig = { ...DEFAULT_CONFIG, ...config };
const logger = defaultLogger;
/**
* Check if error is retryable
*/
function isRetryable(context: PluginErrorContext): boolean {
// Check if we've exceeded max retries
if (context.attempts >= pluginConfig.maxRetries) {
return false;
}
// Check status code if response is available
if (context.response) {
return pluginConfig.retryableStatusCodes.includes(context.response.statusCode);
}
// Check error code/type
const errorCode = (context.error as any).code;
const errorType = context.error.name;
if (errorCode && pluginConfig.retryableErrors.includes(errorCode)) {
return true;
}
if (pluginConfig.retryableErrors.includes(errorType)) {
return true;
}
// Check for timeout errors
if (
errorType === 'TimeoutError' ||
context.error.message.toLowerCase().includes('timeout')
) {
return true;
}
// Check for connection errors
if (
errorType === 'ConnectionError' ||
context.error.message.toLowerCase().includes('connection')
) {
return true;
}
return false;
}
/**
* Calculate retry delay with exponential backoff
*/
function calculateDelay(attempt: number): number {
const delay = pluginConfig.initialDelay * Math.pow(pluginConfig.backoffMultiplier, attempt);
return Math.min(delay, pluginConfig.maxDelay);
}
/**
* Sleep for specified duration
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
return {
name: 'retry',
version: '1.0.0',
priority: 90, // Execute late, close to the actual request
onError: async (context: PluginErrorContext) => {
// Check if error is retryable
if (!isRetryable(context)) {
logger.debug('Error not retryable', {
error: context.error.name,
attempts: context.attempts,
maxRetries: pluginConfig.maxRetries,
});
return null;
}
// Calculate delay
const delay = calculateDelay(context.attempts);
logger.info('Retrying request', {
requestId: context.request.requestId,
attempt: context.attempts + 1,
maxRetries: pluginConfig.maxRetries,
delay,
error: context.error.message,
statusCode: context.response?.statusCode,
});
// Wait before retrying
await sleep(delay);
// Note: We don't actually retry the request here because we can't
// access the client from the plugin. Instead, we return null to
// indicate that the error was not handled, and the caller should
// handle the retry logic.
//
// In a real implementation, you would integrate this with the
// connection manager to actually retry the request.
return null;
},
};
}