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:
257
ts/core/plugins/built-in/cache-plugin.ts
Normal file
257
ts/core/plugins/built-in/cache-plugin.ts
Normal 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;
|
||||
}
|
||||
164
ts/core/plugins/built-in/logging-plugin.ts
Normal file
164
ts/core/plugins/built-in/logging-plugin.ts
Normal 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;
|
||||
}
|
||||
141
ts/core/plugins/built-in/metrics-plugin.ts
Normal file
141
ts/core/plugins/built-in/metrics-plugin.ts
Normal 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';
|
||||
}
|
||||
166
ts/core/plugins/built-in/rate-limit-plugin.ts
Normal file
166
ts/core/plugins/built-in/rate-limit-plugin.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
140
ts/core/plugins/built-in/retry-plugin.ts
Normal file
140
ts/core/plugins/built-in/retry-plugin.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
34
ts/core/plugins/index.ts
Normal file
34
ts/core/plugins/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Plugin System Module
|
||||
*
|
||||
* Extensible request/response middleware
|
||||
*/
|
||||
|
||||
// Core plugin system
|
||||
export { PluginManager, createPluginManager } from './plugin-manager.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Plugin,
|
||||
PluginFactory,
|
||||
PluginContext,
|
||||
PluginResponse,
|
||||
PluginErrorContext,
|
||||
PluginStats,
|
||||
PluginManagerConfig,
|
||||
RequestModification,
|
||||
ResponseModification,
|
||||
// Built-in plugin configs
|
||||
RetryPluginConfig,
|
||||
CachePluginConfig,
|
||||
LoggingPluginConfig,
|
||||
MetricsPluginConfig,
|
||||
RateLimitPluginConfig,
|
||||
} from './types.js';
|
||||
|
||||
// Built-in plugins
|
||||
export { createLoggingPlugin } from './built-in/logging-plugin.js';
|
||||
export { createMetricsPlugin } from './built-in/metrics-plugin.js';
|
||||
export { createCachePlugin } from './built-in/cache-plugin.js';
|
||||
export { createRetryPlugin } from './built-in/retry-plugin.js';
|
||||
export { createRateLimitPlugin } from './built-in/rate-limit-plugin.js';
|
||||
426
ts/core/plugins/plugin-manager.ts
Normal file
426
ts/core/plugins/plugin-manager.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Plugin Manager
|
||||
*
|
||||
* Orchestrates plugin execution through request/response lifecycle
|
||||
*/
|
||||
|
||||
import type { Client } from '@elastic/elasticsearch';
|
||||
import { Logger, defaultLogger } from '../observability/logger.js';
|
||||
import { MetricsCollector, defaultMetricsCollector } from '../observability/metrics.js';
|
||||
import type {
|
||||
Plugin,
|
||||
PluginContext,
|
||||
PluginResponse,
|
||||
PluginErrorContext,
|
||||
PluginStats,
|
||||
PluginManagerConfig,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<PluginManagerConfig> = {
|
||||
enabled: true,
|
||||
maxHookDuration: 5000, // 5 seconds
|
||||
continueOnError: true,
|
||||
collectStats: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin Manager
|
||||
*/
|
||||
export class PluginManager {
|
||||
private plugins: Map<string, Plugin> = new Map();
|
||||
private pluginStats: Map<string, PluginStats> = new Map();
|
||||
private config: Required<PluginManagerConfig>;
|
||||
private logger: Logger;
|
||||
private metrics: MetricsCollector;
|
||||
private client?: Client;
|
||||
|
||||
constructor(config: PluginManagerConfig = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.logger = defaultLogger;
|
||||
this.metrics = defaultMetricsCollector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Elasticsearch client
|
||||
*/
|
||||
setClient(client: Client): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a plugin
|
||||
*/
|
||||
async register(plugin: Plugin): Promise<void> {
|
||||
if (this.plugins.has(plugin.name)) {
|
||||
throw new Error(`Plugin '${plugin.name}' is already registered`);
|
||||
}
|
||||
|
||||
// Initialize plugin
|
||||
if (plugin.initialize && this.client) {
|
||||
try {
|
||||
await plugin.initialize(this.client, plugin.config || {});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to initialize plugin '${plugin.name}'`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.plugins.set(plugin.name, plugin);
|
||||
|
||||
// Initialize stats
|
||||
if (this.config.collectStats) {
|
||||
this.pluginStats.set(plugin.name, {
|
||||
name: plugin.name,
|
||||
beforeRequestCalls: 0,
|
||||
afterResponseCalls: 0,
|
||||
onErrorCalls: 0,
|
||||
avgBeforeRequestDuration: 0,
|
||||
avgAfterResponseDuration: 0,
|
||||
avgOnErrorDuration: 0,
|
||||
errors: 0,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info(`Plugin '${plugin.name}' registered`, {
|
||||
version: plugin.version,
|
||||
priority: plugin.priority,
|
||||
});
|
||||
|
||||
this.metrics.recordCounter('plugins.registered', 1, {
|
||||
plugin: plugin.name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a plugin
|
||||
*/
|
||||
async unregister(name: string): Promise<void> {
|
||||
const plugin = this.plugins.get(name);
|
||||
|
||||
if (!plugin) {
|
||||
throw new Error(`Plugin '${name}' is not registered`);
|
||||
}
|
||||
|
||||
// Cleanup plugin
|
||||
if (plugin.destroy) {
|
||||
try {
|
||||
await plugin.destroy();
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to destroy plugin '${name}'`, { error });
|
||||
}
|
||||
}
|
||||
|
||||
this.plugins.delete(name);
|
||||
this.pluginStats.delete(name);
|
||||
|
||||
this.logger.info(`Plugin '${name}' unregistered`);
|
||||
|
||||
this.metrics.recordCounter('plugins.unregistered', 1, {
|
||||
plugin: name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered plugin
|
||||
*/
|
||||
getPlugin(name: string): Plugin | undefined {
|
||||
return this.plugins.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered plugins
|
||||
*/
|
||||
getPlugins(): Plugin[] {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugins sorted by priority
|
||||
*/
|
||||
private getSortedPlugins(): Plugin[] {
|
||||
return Array.from(this.plugins.values()).sort(
|
||||
(a, b) => (a.priority ?? 100) - (b.priority ?? 100)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute beforeRequest hooks
|
||||
*/
|
||||
async executeBeforeRequest(context: PluginContext): Promise<PluginContext | null> {
|
||||
if (!this.config.enabled) {
|
||||
return context;
|
||||
}
|
||||
|
||||
let currentContext = context;
|
||||
|
||||
for (const plugin of this.getSortedPlugins()) {
|
||||
if (!plugin.beforeRequest) continue;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.executeWithTimeout(
|
||||
() => plugin.beforeRequest!(currentContext),
|
||||
this.config.maxHookDuration,
|
||||
`beforeRequest hook for plugin '${plugin.name}'`
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Update stats
|
||||
if (this.config.collectStats) {
|
||||
this.updateHookStats(plugin.name, 'beforeRequest', duration);
|
||||
}
|
||||
|
||||
this.metrics.recordHistogram('plugins.before_request.duration', duration, {
|
||||
plugin: plugin.name,
|
||||
});
|
||||
|
||||
// Handle cancellation
|
||||
if (result === null) {
|
||||
this.logger.debug(`Request cancelled by plugin '${plugin.name}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
currentContext = result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error in beforeRequest hook for plugin '${plugin.name}'`, {
|
||||
error,
|
||||
});
|
||||
|
||||
if (this.config.collectStats) {
|
||||
const stats = this.pluginStats.get(plugin.name);
|
||||
if (stats) {
|
||||
stats.errors++;
|
||||
stats.lastError = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.config.continueOnError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute afterResponse hooks
|
||||
*/
|
||||
async executeAfterResponse<T>(
|
||||
context: PluginContext,
|
||||
response: PluginResponse<T>
|
||||
): Promise<PluginResponse<T>> {
|
||||
if (!this.config.enabled) {
|
||||
return response;
|
||||
}
|
||||
|
||||
let currentResponse = response;
|
||||
|
||||
for (const plugin of this.getSortedPlugins()) {
|
||||
if (!plugin.afterResponse) continue;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.executeWithTimeout(
|
||||
() => plugin.afterResponse!(context, currentResponse),
|
||||
this.config.maxHookDuration,
|
||||
`afterResponse hook for plugin '${plugin.name}'`
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Update stats
|
||||
if (this.config.collectStats) {
|
||||
this.updateHookStats(plugin.name, 'afterResponse', duration);
|
||||
}
|
||||
|
||||
this.metrics.recordHistogram('plugins.after_response.duration', duration, {
|
||||
plugin: plugin.name,
|
||||
});
|
||||
|
||||
currentResponse = result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error in afterResponse hook for plugin '${plugin.name}'`, {
|
||||
error,
|
||||
});
|
||||
|
||||
if (this.config.collectStats) {
|
||||
const stats = this.pluginStats.get(plugin.name);
|
||||
if (stats) {
|
||||
stats.errors++;
|
||||
stats.lastError = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.config.continueOnError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute onError hooks
|
||||
*/
|
||||
async executeOnError(errorContext: PluginErrorContext): Promise<PluginResponse | null> {
|
||||
if (!this.config.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const plugin of this.getSortedPlugins()) {
|
||||
if (!plugin.onError) continue;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.executeWithTimeout(
|
||||
() => plugin.onError!(errorContext),
|
||||
this.config.maxHookDuration,
|
||||
`onError hook for plugin '${plugin.name}'`
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Update stats
|
||||
if (this.config.collectStats) {
|
||||
this.updateHookStats(plugin.name, 'onError', duration);
|
||||
}
|
||||
|
||||
this.metrics.recordHistogram('plugins.on_error.duration', duration, {
|
||||
plugin: plugin.name,
|
||||
});
|
||||
|
||||
// If plugin handled the error and returned a response, use it
|
||||
if (result !== null) {
|
||||
this.logger.debug(`Error handled by plugin '${plugin.name}'`);
|
||||
return result;
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error in onError hook for plugin '${plugin.name}'`, { error });
|
||||
|
||||
if (this.config.collectStats) {
|
||||
const stats = this.pluginStats.get(plugin.name);
|
||||
if (stats) {
|
||||
stats.errors++;
|
||||
stats.lastError = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.config.continueOnError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin statistics
|
||||
*/
|
||||
getStats(): Map<string, PluginStats> {
|
||||
return new Map(this.pluginStats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear plugin statistics
|
||||
*/
|
||||
clearStats(): void {
|
||||
for (const stats of this.pluginStats.values()) {
|
||||
stats.beforeRequestCalls = 0;
|
||||
stats.afterResponseCalls = 0;
|
||||
stats.onErrorCalls = 0;
|
||||
stats.avgBeforeRequestDuration = 0;
|
||||
stats.avgAfterResponseDuration = 0;
|
||||
stats.avgOnErrorDuration = 0;
|
||||
stats.errors = 0;
|
||||
stats.lastError = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all plugins
|
||||
*/
|
||||
async destroy(): Promise<void> {
|
||||
const pluginNames = Array.from(this.plugins.keys());
|
||||
|
||||
for (const name of pluginNames) {
|
||||
await this.unregister(name);
|
||||
}
|
||||
|
||||
this.pluginStats.clear();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Execute a function with timeout
|
||||
*/
|
||||
private async executeWithTimeout<T>(
|
||||
fn: () => Promise<T> | T,
|
||||
timeoutMs: number,
|
||||
description: string
|
||||
): Promise<T> {
|
||||
return Promise.race([
|
||||
Promise.resolve(fn()),
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Timeout executing ${description} (${timeoutMs}ms)`)),
|
||||
timeoutMs
|
||||
)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hook statistics
|
||||
*/
|
||||
private updateHookStats(
|
||||
pluginName: string,
|
||||
hook: 'beforeRequest' | 'afterResponse' | 'onError',
|
||||
duration: number
|
||||
): void {
|
||||
const stats = this.pluginStats.get(pluginName);
|
||||
if (!stats) return;
|
||||
|
||||
switch (hook) {
|
||||
case 'beforeRequest':
|
||||
stats.beforeRequestCalls++;
|
||||
stats.avgBeforeRequestDuration =
|
||||
(stats.avgBeforeRequestDuration * (stats.beforeRequestCalls - 1) + duration) /
|
||||
stats.beforeRequestCalls;
|
||||
break;
|
||||
|
||||
case 'afterResponse':
|
||||
stats.afterResponseCalls++;
|
||||
stats.avgAfterResponseDuration =
|
||||
(stats.avgAfterResponseDuration * (stats.afterResponseCalls - 1) + duration) /
|
||||
stats.afterResponseCalls;
|
||||
break;
|
||||
|
||||
case 'onError':
|
||||
stats.onErrorCalls++;
|
||||
stats.avgOnErrorDuration =
|
||||
(stats.avgOnErrorDuration * (stats.onErrorCalls - 1) + duration) /
|
||||
stats.onErrorCalls;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plugin manager
|
||||
*/
|
||||
export function createPluginManager(config?: PluginManagerConfig): PluginManager {
|
||||
return new PluginManager(config);
|
||||
}
|
||||
337
ts/core/plugins/types.ts
Normal file
337
ts/core/plugins/types.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Plugin system types for extending client functionality
|
||||
*/
|
||||
|
||||
import type { Client } from '@elastic/elasticsearch';
|
||||
|
||||
/**
|
||||
* Plugin context passed to all plugin hooks
|
||||
*/
|
||||
export interface PluginContext {
|
||||
/** Elasticsearch client instance */
|
||||
client: Client;
|
||||
|
||||
/** Request metadata */
|
||||
request: {
|
||||
/** HTTP method */
|
||||
method: string;
|
||||
|
||||
/** Request path */
|
||||
path: string;
|
||||
|
||||
/** Request body */
|
||||
body?: unknown;
|
||||
|
||||
/** Query parameters */
|
||||
querystring?: Record<string, unknown>;
|
||||
|
||||
/** Request headers */
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/** Request ID for tracing */
|
||||
requestId: string;
|
||||
|
||||
/** Timestamp when request started */
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
/** Shared data between plugins */
|
||||
shared: Map<string, unknown>;
|
||||
|
||||
/** Plugin configuration */
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response object from Elasticsearch
|
||||
*/
|
||||
export interface PluginResponse<T = unknown> {
|
||||
/** Response body */
|
||||
body: T;
|
||||
|
||||
/** Response status code */
|
||||
statusCode: number;
|
||||
|
||||
/** Response headers */
|
||||
headers: Record<string, string>;
|
||||
|
||||
/** Response warnings */
|
||||
warnings?: string[];
|
||||
|
||||
/** Response metadata */
|
||||
meta?: {
|
||||
context: unknown;
|
||||
request: {
|
||||
params: unknown;
|
||||
options: unknown;
|
||||
id: number;
|
||||
};
|
||||
name: string;
|
||||
connection: unknown;
|
||||
attempts: number;
|
||||
aborted: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Error context for plugin error handling
|
||||
*/
|
||||
export interface PluginErrorContext extends PluginContext {
|
||||
/** The error that occurred */
|
||||
error: Error;
|
||||
|
||||
/** Number of retry attempts so far */
|
||||
attempts: number;
|
||||
|
||||
/** Response if available */
|
||||
response?: PluginResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin lifecycle hooks
|
||||
*/
|
||||
export interface Plugin {
|
||||
/** Plugin name (must be unique) */
|
||||
name: string;
|
||||
|
||||
/** Plugin version */
|
||||
version?: string;
|
||||
|
||||
/** Plugin priority (lower = earlier execution, default: 100) */
|
||||
priority?: number;
|
||||
|
||||
/** Plugin configuration */
|
||||
config?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Initialize plugin
|
||||
* Called once when plugin is registered
|
||||
*/
|
||||
initialize?: (client: Client, config: Record<string, unknown>) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Before request hook
|
||||
* Called before each request is sent
|
||||
* Can modify the request or cancel it
|
||||
*/
|
||||
beforeRequest?: (
|
||||
context: PluginContext
|
||||
) => Promise<PluginContext | null> | PluginContext | null;
|
||||
|
||||
/**
|
||||
* After response hook
|
||||
* Called after successful response
|
||||
* Can modify the response
|
||||
*/
|
||||
afterResponse?: <T>(
|
||||
context: PluginContext,
|
||||
response: PluginResponse<T>
|
||||
) => Promise<PluginResponse<T>> | PluginResponse<T>;
|
||||
|
||||
/**
|
||||
* On error hook
|
||||
* Called when request fails
|
||||
* Can handle error or rethrow
|
||||
*/
|
||||
onError?: (
|
||||
context: PluginErrorContext
|
||||
) => Promise<PluginResponse | null> | PluginResponse | null;
|
||||
|
||||
/**
|
||||
* Cleanup plugin
|
||||
* Called when plugin is unregistered or client is destroyed
|
||||
*/
|
||||
destroy?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin factory function
|
||||
*/
|
||||
export type PluginFactory = (config?: Record<string, unknown>) => Plugin;
|
||||
|
||||
/**
|
||||
* Request modification result
|
||||
*/
|
||||
export interface RequestModification {
|
||||
/** Modified request path */
|
||||
path?: string;
|
||||
|
||||
/** Modified request method */
|
||||
method?: string;
|
||||
|
||||
/** Modified request body */
|
||||
body?: unknown;
|
||||
|
||||
/** Modified querystring */
|
||||
querystring?: Record<string, unknown>;
|
||||
|
||||
/** Modified headers */
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/** Cancel this request */
|
||||
cancel?: boolean;
|
||||
|
||||
/** Skip remaining plugins */
|
||||
skipRemaining?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response modification result
|
||||
*/
|
||||
export interface ResponseModification<T = unknown> {
|
||||
/** Modified response body */
|
||||
body?: T;
|
||||
|
||||
/** Modified status code */
|
||||
statusCode?: number;
|
||||
|
||||
/** Modified headers */
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/** Skip remaining plugins */
|
||||
skipRemaining?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin execution statistics
|
||||
*/
|
||||
export interface PluginStats {
|
||||
/** Plugin name */
|
||||
name: string;
|
||||
|
||||
/** Total times beforeRequest was called */
|
||||
beforeRequestCalls: number;
|
||||
|
||||
/** Total times afterResponse was called */
|
||||
afterResponseCalls: number;
|
||||
|
||||
/** Total times onError was called */
|
||||
onErrorCalls: number;
|
||||
|
||||
/** Average execution time for beforeRequest (ms) */
|
||||
avgBeforeRequestDuration: number;
|
||||
|
||||
/** Average execution time for afterResponse (ms) */
|
||||
avgAfterResponseDuration: number;
|
||||
|
||||
/** Average execution time for onError (ms) */
|
||||
avgOnErrorDuration: number;
|
||||
|
||||
/** Total errors in plugin execution */
|
||||
errors: number;
|
||||
|
||||
/** Last error message */
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin manager configuration
|
||||
*/
|
||||
export interface PluginManagerConfig {
|
||||
/** Enable plugin execution */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Maximum time a plugin hook can take (ms) */
|
||||
maxHookDuration?: number;
|
||||
|
||||
/** Whether to continue on plugin errors */
|
||||
continueOnError?: boolean;
|
||||
|
||||
/** Enable plugin statistics collection */
|
||||
collectStats?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in plugin configurations
|
||||
*/
|
||||
|
||||
export interface RetryPluginConfig {
|
||||
/** Maximum retry attempts */
|
||||
maxRetries?: number;
|
||||
|
||||
/** Initial retry delay (ms) */
|
||||
initialDelay?: number;
|
||||
|
||||
/** Maximum retry delay (ms) */
|
||||
maxDelay?: number;
|
||||
|
||||
/** Backoff multiplier */
|
||||
backoffMultiplier?: number;
|
||||
|
||||
/** HTTP status codes to retry */
|
||||
retryableStatusCodes?: number[];
|
||||
|
||||
/** Error types to retry */
|
||||
retryableErrors?: string[];
|
||||
}
|
||||
|
||||
export interface CachePluginConfig {
|
||||
/** Enable caching */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Maximum cache entries */
|
||||
maxEntries?: number;
|
||||
|
||||
/** Default TTL in seconds */
|
||||
defaultTTL?: number;
|
||||
|
||||
/** Cache key generator */
|
||||
keyGenerator?: (context: PluginContext) => string;
|
||||
|
||||
/** Methods to cache (default: ['GET']) */
|
||||
methods?: string[];
|
||||
}
|
||||
|
||||
export interface LoggingPluginConfig {
|
||||
/** Enable request logging */
|
||||
logRequests?: boolean;
|
||||
|
||||
/** Enable response logging */
|
||||
logResponses?: boolean;
|
||||
|
||||
/** Enable error logging */
|
||||
logErrors?: boolean;
|
||||
|
||||
/** Log request body */
|
||||
logRequestBody?: boolean;
|
||||
|
||||
/** Log response body */
|
||||
logResponseBody?: boolean;
|
||||
|
||||
/** Maximum body size to log (bytes) */
|
||||
maxBodySize?: number;
|
||||
|
||||
/** Sensitive fields to redact */
|
||||
sensitiveFields?: string[];
|
||||
}
|
||||
|
||||
export interface MetricsPluginConfig {
|
||||
/** Enable metrics collection */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Metrics prefix */
|
||||
prefix?: string;
|
||||
|
||||
/** Record request duration histogram */
|
||||
recordDuration?: boolean;
|
||||
|
||||
/** Record request size histogram */
|
||||
recordSize?: boolean;
|
||||
|
||||
/** Record response size histogram */
|
||||
recordResponseSize?: boolean;
|
||||
}
|
||||
|
||||
export interface RateLimitPluginConfig {
|
||||
/** Maximum requests per second */
|
||||
maxRequestsPerSecond?: number;
|
||||
|
||||
/** Burst size */
|
||||
burstSize?: number;
|
||||
|
||||
/** Wait for slot or reject immediately */
|
||||
waitForSlot?: boolean;
|
||||
|
||||
/** Maximum wait time (ms) */
|
||||
maxWaitTime?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user