@crossdelta/cloudevents 0.1.1
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 +125 -0
- package/dist/src/adapters/cloudevents/cloudevents.d.ts +14 -0
- package/dist/src/adapters/cloudevents/cloudevents.js +58 -0
- package/dist/src/adapters/cloudevents/index.d.ts +8 -0
- package/dist/src/adapters/cloudevents/index.js +7 -0
- package/dist/src/adapters/cloudevents/parsers/binary-mode.d.ts +5 -0
- package/dist/src/adapters/cloudevents/parsers/binary-mode.js +32 -0
- package/dist/src/adapters/cloudevents/parsers/pubsub.d.ts +5 -0
- package/dist/src/adapters/cloudevents/parsers/pubsub.js +54 -0
- package/dist/src/adapters/cloudevents/parsers/raw-event.d.ts +5 -0
- package/dist/src/adapters/cloudevents/parsers/raw-event.js +17 -0
- package/dist/src/adapters/cloudevents/parsers/structured-mode.d.ts +5 -0
- package/dist/src/adapters/cloudevents/parsers/structured-mode.js +18 -0
- package/dist/src/adapters/cloudevents/types.d.ts +29 -0
- package/dist/src/adapters/cloudevents/types.js +1 -0
- package/dist/src/domain/discovery.d.ts +24 -0
- package/dist/src/domain/discovery.js +137 -0
- package/dist/src/domain/handler-factory.d.ts +24 -0
- package/dist/src/domain/handler-factory.js +62 -0
- package/dist/src/domain/index.d.ts +5 -0
- package/dist/src/domain/index.js +3 -0
- package/dist/src/domain/types.d.ts +52 -0
- package/dist/src/domain/types.js +6 -0
- package/dist/src/domain/validation.d.ts +37 -0
- package/dist/src/domain/validation.js +53 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +4 -0
- package/dist/src/infrastructure/errors.d.ts +53 -0
- package/dist/src/infrastructure/errors.js +54 -0
- package/dist/src/infrastructure/index.d.ts +4 -0
- package/dist/src/infrastructure/index.js +2 -0
- package/dist/src/infrastructure/logging.d.ts +18 -0
- package/dist/src/infrastructure/logging.js +27 -0
- package/dist/src/middlewares/cloudevents-middleware.d.ts +171 -0
- package/dist/src/middlewares/cloudevents-middleware.js +276 -0
- package/dist/src/middlewares/index.d.ts +1 -0
- package/dist/src/middlewares/index.js +1 -0
- package/dist/src/processing/dlq-safe.d.ts +34 -0
- package/dist/src/processing/dlq-safe.js +91 -0
- package/dist/src/processing/handler-cache.d.ts +36 -0
- package/dist/src/processing/handler-cache.js +94 -0
- package/dist/src/processing/index.d.ts +3 -0
- package/dist/src/processing/index.js +3 -0
- package/dist/src/processing/validation.d.ts +41 -0
- package/dist/src/processing/validation.js +48 -0
- package/dist/src/publishing/index.d.ts +2 -0
- package/dist/src/publishing/index.js +2 -0
- package/dist/src/publishing/nats.publisher.d.ts +22 -0
- package/dist/src/publishing/nats.publisher.js +66 -0
- package/dist/src/publishing/pubsub.publisher.d.ts +39 -0
- package/dist/src/publishing/pubsub.publisher.js +84 -0
- package/dist/src/transports/nats/index.d.ts +2 -0
- package/dist/src/transports/nats/index.js +2 -0
- package/dist/src/transports/nats/nats-consumer.d.ts +30 -0
- package/dist/src/transports/nats/nats-consumer.js +54 -0
- package/dist/src/transports/nats/nats-message-processor.d.ts +22 -0
- package/dist/src/transports/nats/nats-message-processor.js +95 -0
- package/package.json +46 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { parseEventFromContext } from '../adapters/cloudevents';
|
|
2
|
+
import { handleErrorWithContext, logger } from '../infrastructure';
|
|
3
|
+
import { addProcessedMessage, clearHandlerCache, createCacheKey, createProcessingContext, getCachedHandlers, isDlqSafeMode, isHandlerMatching, publishRecoverableError, quarantineMessage, setupHandlers, throwValidationError, validateEventData, } from '../processing';
|
|
4
|
+
// Export clearHandlerCache for testing
|
|
5
|
+
export { clearHandlerCache };
|
|
6
|
+
/**
|
|
7
|
+
* Logs middleware initialization status
|
|
8
|
+
*/
|
|
9
|
+
const logInitialization = (options, log) => {
|
|
10
|
+
if (isDlqSafeMode(options)) {
|
|
11
|
+
const quarantine = options.quarantineTopic || 'not configured';
|
|
12
|
+
const errorTopic = options.errorTopic || 'not configured';
|
|
13
|
+
const projectId = options.projectId || 'default';
|
|
14
|
+
const source = options.source || 'auto-detected';
|
|
15
|
+
if (log === 'pretty') {
|
|
16
|
+
logger.info('DLQ-Safe mode enabled');
|
|
17
|
+
logger.info(` • Quarantine topic: ${quarantine}`);
|
|
18
|
+
logger.info(` • Error topic: ${errorTopic}`);
|
|
19
|
+
logger.info(` • Project ID: ${projectId}`);
|
|
20
|
+
logger.info(` • Source: ${source}`);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
logger.info('CloudEvents middleware initialized', {
|
|
24
|
+
mode: 'DLQ-Safe',
|
|
25
|
+
quarantineTopic: quarantine,
|
|
26
|
+
errorTopic: errorTopic,
|
|
27
|
+
projectId: projectId,
|
|
28
|
+
source: source,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
if (log === 'pretty') {
|
|
34
|
+
logger.info('DLQ-Safe mode disabled - using standard error handling');
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
logger.info('CloudEvents middleware initialized', {
|
|
38
|
+
mode: 'Standard',
|
|
39
|
+
note: 'Using standard error handling',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Executes a single handler with error handling
|
|
46
|
+
*/
|
|
47
|
+
const executeHandler = async (handler, eventData, context) => {
|
|
48
|
+
try {
|
|
49
|
+
await handler.handle(eventData, context);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
logger.error(`Handler execution failed for ${handler.name}:`, error);
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Processes handlers in DLQ-safe or standard mode
|
|
58
|
+
*/
|
|
59
|
+
const processHandlerSafely = async (handler, eventData, ctx, processingContext, options) => {
|
|
60
|
+
const validationResult = validateEventData(handler, eventData);
|
|
61
|
+
if ('error' in validationResult) {
|
|
62
|
+
if (validationResult.shouldSkip) {
|
|
63
|
+
return; // Skip this handler in safeParse mode
|
|
64
|
+
}
|
|
65
|
+
if (isDlqSafeMode(options)) {
|
|
66
|
+
// Non-blocking quarantine - don't wait for DLQ publishing
|
|
67
|
+
queueMicrotask(() => {
|
|
68
|
+
quarantineMessage(processingContext, 'validation_error', options, validationResult.error).catch((error) => {
|
|
69
|
+
logger.error('Failed to quarantine validation error:', error);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
throwValidationError(handler.name, validationResult.error);
|
|
75
|
+
}
|
|
76
|
+
// Execute handler
|
|
77
|
+
try {
|
|
78
|
+
await executeHandler(handler, eventData, ctx);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (isDlqSafeMode(options)) {
|
|
82
|
+
// Non-blocking error publishing - don't wait for DLQ publishing
|
|
83
|
+
queueMicrotask(() => {
|
|
84
|
+
publishRecoverableError(processingContext, error, options).catch((publishError) => {
|
|
85
|
+
logger.error('Failed to publish recoverable error:', publishError);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Executes all matching handlers for an event
|
|
95
|
+
*/
|
|
96
|
+
const executeMatchingHandlers = async (handlers, eventType, eventData, ctx, processingContext, options) => {
|
|
97
|
+
const matchingHandlers = handlers.filter(isHandlerMatching(eventType, eventData, ctx));
|
|
98
|
+
if (matchingHandlers.length === 0) {
|
|
99
|
+
const errorMessage = `No handlers found for event type: ${eventType}`;
|
|
100
|
+
if (isDlqSafeMode(options)) {
|
|
101
|
+
// Non-blocking quarantine - don't wait for DLQ publishing
|
|
102
|
+
queueMicrotask(() => {
|
|
103
|
+
quarantineMessage(processingContext, 'no_handler', options, new Error(errorMessage)).catch((error) => {
|
|
104
|
+
logger.error('Failed to quarantine no_handler error:', error);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
throw new Error(errorMessage);
|
|
110
|
+
}
|
|
111
|
+
// Process all matching handlers
|
|
112
|
+
await Promise.all(matchingHandlers.map((handler) => processHandlerSafely(handler, eventData, ctx, processingContext, options)));
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Main CloudEvent processing function
|
|
116
|
+
*/
|
|
117
|
+
const processCloudEvent = async (ctx, options) => {
|
|
118
|
+
const cacheKey = createCacheKey(options.discover, options.handlers);
|
|
119
|
+
const handlers = getCachedHandlers(cacheKey);
|
|
120
|
+
try {
|
|
121
|
+
const { event, cloudEventData } = await parseEventFromContext(ctx);
|
|
122
|
+
// Use cloudEventData as primary source, fallback to event
|
|
123
|
+
const eventData = cloudEventData || event;
|
|
124
|
+
if (!eventData) {
|
|
125
|
+
throw new Error('Failed to parse CloudEvent from request');
|
|
126
|
+
}
|
|
127
|
+
const eventType = eventData.type;
|
|
128
|
+
const eventPayload = eventData.data;
|
|
129
|
+
const context = cloudEventData || {};
|
|
130
|
+
const messageId = eventData.id || 'unknown';
|
|
131
|
+
// Deduplication check
|
|
132
|
+
const dedupeSet = options.processedMessageIds || new Set();
|
|
133
|
+
if (dedupeSet.has(messageId)) {
|
|
134
|
+
logger.info(`Duplicate message ${messageId} detected, skipping processing`);
|
|
135
|
+
return ctx.json({ status: 'duplicate', messageId }, 200);
|
|
136
|
+
}
|
|
137
|
+
addProcessedMessage(messageId);
|
|
138
|
+
dedupeSet.add(messageId);
|
|
139
|
+
const processingContext = createProcessingContext(eventType, eventPayload, context, event || eventData);
|
|
140
|
+
if (options.log) {
|
|
141
|
+
logger.info(`Processing event type: ${eventType}`);
|
|
142
|
+
}
|
|
143
|
+
// Execute handlers
|
|
144
|
+
await executeMatchingHandlers(handlers, eventType, eventPayload, context, processingContext, options);
|
|
145
|
+
// Return success response
|
|
146
|
+
return isDlqSafeMode(options)
|
|
147
|
+
? ctx.body(null, 204) // Always 204 in DLQ-safe mode
|
|
148
|
+
: ctx.json({ status: 'processed', eventType, messageId }, 200);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
// Handle ValidationError specifically
|
|
152
|
+
if (error && typeof error === 'object' && 'type' in error && error.type === 'ValidationError') {
|
|
153
|
+
return ctx.json({ error }, 422);
|
|
154
|
+
}
|
|
155
|
+
handleErrorWithContext(error, ctx, { isDlqSafe: isDlqSafeMode(options) });
|
|
156
|
+
return ctx.json({ error: 'Internal Server Error' }, 500);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* CloudEvents middleware for Hono applications
|
|
161
|
+
*
|
|
162
|
+
* Provides automatic CloudEvent processing with handler discovery, validation,
|
|
163
|
+
* and optional DLQ-Safe mode for Google Cloud Pub/Sub Push endpoints.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```typescript
|
|
167
|
+
* import { Hono } from 'hono'
|
|
168
|
+
* import { cloudEvents } from '@orderboss/cloudevents'
|
|
169
|
+
*
|
|
170
|
+
* const app = new Hono()
|
|
171
|
+
*
|
|
172
|
+
* // Basic usage with handler discovery
|
|
173
|
+
* app.use('*', cloudEvents({
|
|
174
|
+
* discover: './src/events/*.event.{ts,js}',
|
|
175
|
+
* log: true
|
|
176
|
+
* }))
|
|
177
|
+
*
|
|
178
|
+
* // Manual handler registration
|
|
179
|
+
* app.use('*', cloudEvents({
|
|
180
|
+
* handlers: [CustomerCreatedHandler, OrderProcessedHandler],
|
|
181
|
+
* log: 'pretty'
|
|
182
|
+
* }))
|
|
183
|
+
*
|
|
184
|
+
* // DLQ-Safe mode for Pub/Sub Push
|
|
185
|
+
* app.use('*', cloudEvents({
|
|
186
|
+
* discover: './src/events/*.event.{ts,js}',
|
|
187
|
+
* quarantineTopic: 'quarantine-topic',
|
|
188
|
+
* errorTopic: 'error-topic',
|
|
189
|
+
* projectId: 'my-project',
|
|
190
|
+
* log: 'structured'
|
|
191
|
+
* }))
|
|
192
|
+
* ```
|
|
193
|
+
*
|
|
194
|
+
* @param options - Configuration options for the middleware
|
|
195
|
+
* @param options.discover - Directory path for automatic handler discovery (e.g., './src/events')
|
|
196
|
+
* @param options.handlers - Array of handler constructors for manual registration
|
|
197
|
+
* @param options.log - Enable logging: false (default), true, 'pretty' (dev), or 'structured' (prod)
|
|
198
|
+
* @param options.quarantineTopic - Pub/Sub topic for quarantining invalid messages (enables DLQ-Safe mode)
|
|
199
|
+
* @param options.errorTopic - Pub/Sub topic for publishing recoverable handler errors
|
|
200
|
+
* @param options.projectId - Google Cloud Project ID (defaults to environment detection)
|
|
201
|
+
* @param options.source - CloudEvent source identifier (defaults to auto-detection)
|
|
202
|
+
* @param options.processedMessageIds - Set for message deduplication (automatically managed)
|
|
203
|
+
*
|
|
204
|
+
* @returns Hono middleware function that processes CloudEvents
|
|
205
|
+
*
|
|
206
|
+
* @remarks
|
|
207
|
+
* **Handler Discovery:**
|
|
208
|
+
* - Automatically discovers handlers in the specified directory
|
|
209
|
+
* - Handlers must export a class that extends the base handler pattern
|
|
210
|
+
* - Supports nested directories and TypeScript files
|
|
211
|
+
*
|
|
212
|
+
* **Event Format Support:**
|
|
213
|
+
* - CloudEvents Structured mode (JSON with specversion)
|
|
214
|
+
* - CloudEvents Binary mode (headers + data)
|
|
215
|
+
* - Google Pub/Sub Push format
|
|
216
|
+
* - Raw event data (wrapped in CloudEvent)
|
|
217
|
+
*
|
|
218
|
+
* **DLQ-Safe Mode:**
|
|
219
|
+
* - Activated when `quarantineTopic` is specified
|
|
220
|
+
* - Always returns HTTP 204 to prevent Pub/Sub redelivery
|
|
221
|
+
* - Quarantines invalid messages to specified topic
|
|
222
|
+
* - Publishes handler errors to error topic
|
|
223
|
+
* - All DLQ operations are non-blocking for optimal performance
|
|
224
|
+
*
|
|
225
|
+
* **Standard Mode:**
|
|
226
|
+
* - Returns HTTP 200 for successful processing
|
|
227
|
+
* - Returns HTTP 422 for validation errors
|
|
228
|
+
* - Returns HTTP 500 for handler errors
|
|
229
|
+
* - Allows Pub/Sub DLQ behavior for unhandled errors
|
|
230
|
+
*
|
|
231
|
+
* **Performance Features:**
|
|
232
|
+
* - Handler caching for improved startup time
|
|
233
|
+
* - Dynamic parser loading for optimal bundle size
|
|
234
|
+
* - Message deduplication based on CloudEvent ID
|
|
235
|
+
* - Background processing for DLQ operations
|
|
236
|
+
*
|
|
237
|
+
* @since 1.0.0
|
|
238
|
+
*/
|
|
239
|
+
export function cloudEvents(options = {}) {
|
|
240
|
+
const { discover, handlers = [], log = false } = options;
|
|
241
|
+
const cacheKey = createCacheKey(discover, handlers);
|
|
242
|
+
// Setup handlers immediately when middleware is created
|
|
243
|
+
if (log) {
|
|
244
|
+
setupHandlers(cacheKey, discover, handlers, log)
|
|
245
|
+
// Log configuration status based on log format preference
|
|
246
|
+
.then(() => logInitialization(options, log))
|
|
247
|
+
.catch((error) => logger.error('Failed to setup handlers during initialization:', error));
|
|
248
|
+
}
|
|
249
|
+
return async (ctx, next) => {
|
|
250
|
+
if (ctx.req.method !== 'POST') {
|
|
251
|
+
return next();
|
|
252
|
+
}
|
|
253
|
+
await setupHandlers(cacheKey, discover, handlers, log);
|
|
254
|
+
try {
|
|
255
|
+
return await processCloudEvent(ctx, options);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
// In DLQ-safe mode, always return 204 to prevent DLQ
|
|
259
|
+
if (isDlqSafeMode(options)) {
|
|
260
|
+
logger.error('Error during request processing (DLQ-safe mode):', error);
|
|
261
|
+
queueMicrotask(async () => {
|
|
262
|
+
try {
|
|
263
|
+
const processingContext = createProcessingContext('unknown', null, null, null);
|
|
264
|
+
await quarantineMessage(processingContext, 'processing_error', options, error);
|
|
265
|
+
}
|
|
266
|
+
catch (quarantineError) {
|
|
267
|
+
const errorMessage = quarantineError instanceof Error ? quarantineError.message : 'Unknown error';
|
|
268
|
+
logger.error(`Failed to quarantine error: ${errorMessage}`);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
return ctx.body(null, 204);
|
|
272
|
+
}
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { clearHandlerCache, cloudEvents } from './cloudevents-middleware';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { clearHandlerCache, cloudEvents } from './cloudevents-middleware';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLQ-Safe mode utilities
|
|
3
|
+
* Handles quarantine and error publishing for CloudEvents processing
|
|
4
|
+
*/
|
|
5
|
+
export interface ProcessingContext {
|
|
6
|
+
messageId: string;
|
|
7
|
+
eventType: string;
|
|
8
|
+
eventData: unknown;
|
|
9
|
+
eventContext: unknown;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
originalCloudEvent: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface DlqOptions {
|
|
14
|
+
quarantineTopic?: string;
|
|
15
|
+
errorTopic?: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
source?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Checks if DLQ-Safe mode is enabled
|
|
21
|
+
*/
|
|
22
|
+
export declare const isDlqSafeMode: (options: DlqOptions) => boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Publishes a message to the quarantine topic for "poison messages" that can't be processed
|
|
25
|
+
*/
|
|
26
|
+
export declare const quarantineMessage: (processingContext: ProcessingContext, reason: string, options: DlqOptions, error?: unknown) => Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Publishes recoverable processing errors to the error topic
|
|
29
|
+
*/
|
|
30
|
+
export declare const publishRecoverableError: (processingContext: ProcessingContext, error: unknown, options: DlqOptions) => Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Creates processing context from event data
|
|
33
|
+
*/
|
|
34
|
+
export declare const createProcessingContext: (eventType: string, eventData: unknown, context: unknown, originalCloudEvent: unknown) => ProcessingContext;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLQ-Safe mode utilities
|
|
3
|
+
* Handles quarantine and error publishing for CloudEvents processing
|
|
4
|
+
*/
|
|
5
|
+
import { logger } from '../infrastructure';
|
|
6
|
+
import { publishRawEvent } from '../publishing';
|
|
7
|
+
/**
|
|
8
|
+
* Checks if DLQ-Safe mode is enabled
|
|
9
|
+
*/
|
|
10
|
+
export const isDlqSafeMode = (options) => {
|
|
11
|
+
return !!(options.quarantineTopic || options.errorTopic);
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Publishes a message to the quarantine topic for "poison messages" that can't be processed
|
|
15
|
+
*/
|
|
16
|
+
export const quarantineMessage = async (processingContext, reason, options, error) => {
|
|
17
|
+
if (!options.quarantineTopic) {
|
|
18
|
+
logger.warn('No quarantine topic configured, skipping quarantine');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const quarantineData = {
|
|
23
|
+
originalMessageId: processingContext.messageId,
|
|
24
|
+
originalEventType: processingContext.eventType,
|
|
25
|
+
originalEventData: processingContext.eventData,
|
|
26
|
+
originalEventContext: processingContext.eventContext,
|
|
27
|
+
originalCloudEvent: processingContext.originalCloudEvent,
|
|
28
|
+
quarantinedAt: new Date().toISOString(),
|
|
29
|
+
quarantineReason: reason,
|
|
30
|
+
error: error ? String(error) : undefined,
|
|
31
|
+
};
|
|
32
|
+
await publishRawEvent(options.quarantineTopic, 'hono.cloudevents.quarantined', quarantineData, {
|
|
33
|
+
projectId: options.projectId,
|
|
34
|
+
subject: `quarantine.${reason}`,
|
|
35
|
+
source: options.source || 'hono-cloudevents',
|
|
36
|
+
attributes: {
|
|
37
|
+
'quarantine-reason': reason,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
logger.info(`Message ${processingContext.messageId} quarantined: ${reason}`);
|
|
41
|
+
}
|
|
42
|
+
catch (publishError) {
|
|
43
|
+
const errorMessage = publishError instanceof Error ? publishError.message : 'Unknown error';
|
|
44
|
+
logger.error(`Failed to quarantine message ${processingContext.messageId}: ${errorMessage}`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Publishes recoverable processing errors to the error topic
|
|
49
|
+
*/
|
|
50
|
+
export const publishRecoverableError = async (processingContext, error, options) => {
|
|
51
|
+
if (!options.errorTopic) {
|
|
52
|
+
logger.warn('No error topic configured, skipping error publishing');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const errorData = {
|
|
57
|
+
originalMessageId: processingContext.messageId,
|
|
58
|
+
originalEventType: processingContext.eventType,
|
|
59
|
+
originalEventData: processingContext.eventData,
|
|
60
|
+
originalEventContext: processingContext.eventContext,
|
|
61
|
+
originalCloudEvent: processingContext.originalCloudEvent,
|
|
62
|
+
errorTimestamp: new Date().toISOString(),
|
|
63
|
+
error: {
|
|
64
|
+
message: error instanceof Error ? error.message : String(error),
|
|
65
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
66
|
+
type: error instanceof Error ? error.constructor.name : typeof error,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
await publishRawEvent(options.errorTopic, 'hono.cloudevents.processing-error', errorData, {
|
|
70
|
+
projectId: options.projectId,
|
|
71
|
+
subject: 'processing.error',
|
|
72
|
+
source: options.source || 'hono-cloudevents',
|
|
73
|
+
});
|
|
74
|
+
logger.info(`Processing error published for message ${processingContext.messageId}`);
|
|
75
|
+
}
|
|
76
|
+
catch (publishError) {
|
|
77
|
+
const errorMessage = publishError instanceof Error ? publishError.message : 'Unknown error';
|
|
78
|
+
logger.error(`Failed to publish error for message ${processingContext.messageId}: ${errorMessage}`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Creates processing context from event data
|
|
83
|
+
*/
|
|
84
|
+
export const createProcessingContext = (eventType, eventData, context, originalCloudEvent) => ({
|
|
85
|
+
messageId: context?.messageId || 'unknown',
|
|
86
|
+
eventType,
|
|
87
|
+
eventData,
|
|
88
|
+
eventContext: context,
|
|
89
|
+
timestamp: new Date().toISOString(),
|
|
90
|
+
originalCloudEvent,
|
|
91
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler Cache Management
|
|
3
|
+
* Immutable cache operations for CloudEvents handlers
|
|
4
|
+
*/
|
|
5
|
+
import type { ZodTypeAny } from 'zod';
|
|
6
|
+
import { type EnrichedEvent, type HandlerConstructor } from '../domain';
|
|
7
|
+
export interface ProcessedHandler {
|
|
8
|
+
type: string;
|
|
9
|
+
name: string;
|
|
10
|
+
schema: ZodTypeAny;
|
|
11
|
+
handle: (payload: unknown, context?: unknown) => Promise<void>;
|
|
12
|
+
match?: (event: EnrichedEvent<unknown>) => boolean;
|
|
13
|
+
safeParse?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Clears the handler cache. Useful for testing.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export declare const clearHandlerCache: () => void;
|
|
20
|
+
export declare const getCachedHandlers: (key: string) => ProcessedHandler[];
|
|
21
|
+
export declare const setCachedHandlers: (key: string, handlers: ProcessedHandler[]) => void;
|
|
22
|
+
export declare const hasCachedHandlers: (key: string) => boolean;
|
|
23
|
+
export declare const isMessageProcessed: (messageId: string) => boolean;
|
|
24
|
+
export declare const addProcessedMessage: (messageId: string) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Converts HandlerConstructor to ProcessedHandler
|
|
27
|
+
*/
|
|
28
|
+
export declare const processHandler: (HandlerClass: HandlerConstructor) => ProcessedHandler | null;
|
|
29
|
+
/**
|
|
30
|
+
* Creates a cache key from configuration with environment awareness
|
|
31
|
+
*/
|
|
32
|
+
export declare const createCacheKey: (discover?: string, handlers?: HandlerConstructor[]) => string;
|
|
33
|
+
/**
|
|
34
|
+
* Setup handlers with caching
|
|
35
|
+
*/
|
|
36
|
+
export declare const setupHandlers: (cacheKey: string, discover?: string, handlers?: HandlerConstructor[], log?: boolean | "pretty" | "structured") => Promise<void>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler Cache Management
|
|
3
|
+
* Immutable cache operations for CloudEvents handlers
|
|
4
|
+
*/
|
|
5
|
+
import { discoverHandlers, extractTypeFromSchema } from '../domain';
|
|
6
|
+
import { logger } from '../infrastructure/logging';
|
|
7
|
+
const createEmptyCache = () => new Map();
|
|
8
|
+
const createEmptyMessageIds = () => new Set();
|
|
9
|
+
// Global state (necessary for middleware persistence)
|
|
10
|
+
let handlerCache = createEmptyCache();
|
|
11
|
+
let processedMessageIds = createEmptyMessageIds();
|
|
12
|
+
/**
|
|
13
|
+
* Clears the handler cache. Useful for testing.
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export const clearHandlerCache = () => {
|
|
17
|
+
handlerCache = createEmptyCache();
|
|
18
|
+
processedMessageIds = createEmptyMessageIds();
|
|
19
|
+
};
|
|
20
|
+
// Pure cache operations
|
|
21
|
+
export const getCachedHandlers = (key) => handlerCache.get(key) || [];
|
|
22
|
+
export const setCachedHandlers = (key, handlers) => {
|
|
23
|
+
handlerCache = new Map(handlerCache).set(key, handlers);
|
|
24
|
+
};
|
|
25
|
+
export const hasCachedHandlers = (key) => handlerCache.has(key);
|
|
26
|
+
export const isMessageProcessed = (messageId) => processedMessageIds.has(messageId);
|
|
27
|
+
export const addProcessedMessage = (messageId) => {
|
|
28
|
+
processedMessageIds = new Set(processedMessageIds).add(messageId);
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Converts HandlerConstructor to ProcessedHandler
|
|
32
|
+
*/
|
|
33
|
+
export const processHandler = (HandlerClass) => {
|
|
34
|
+
const metadata = HandlerClass.__eventarcMetadata;
|
|
35
|
+
if (!metadata)
|
|
36
|
+
return null;
|
|
37
|
+
const instance = new HandlerClass();
|
|
38
|
+
const eventType = metadata.declaredType || extractTypeFromSchema(metadata.schema);
|
|
39
|
+
if (!eventType)
|
|
40
|
+
return null;
|
|
41
|
+
return {
|
|
42
|
+
type: eventType,
|
|
43
|
+
name: HandlerClass.name,
|
|
44
|
+
schema: metadata.schema,
|
|
45
|
+
handle: async (payload, context) => {
|
|
46
|
+
await Promise.resolve(instance.handle(payload, context));
|
|
47
|
+
},
|
|
48
|
+
match: metadata.match,
|
|
49
|
+
safeParse: metadata.safeParse || false,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Creates a cache key from configuration with environment awareness
|
|
54
|
+
*/
|
|
55
|
+
export const createCacheKey = (discover, handlers = []) => {
|
|
56
|
+
const discoverKey = discover || 'no-discover';
|
|
57
|
+
const handlersKey = handlers
|
|
58
|
+
.map((h) => h.name)
|
|
59
|
+
.sort()
|
|
60
|
+
.join(',') || 'no-handlers';
|
|
61
|
+
const envKey = process.env.NODE_ENV || 'development';
|
|
62
|
+
return `${envKey}:${discoverKey}:${handlersKey}`;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Setup handlers with caching
|
|
66
|
+
*/
|
|
67
|
+
export const setupHandlers = async (cacheKey, discover, handlers = [], log = false) => {
|
|
68
|
+
if (hasCachedHandlers(cacheKey))
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
const allHandlers = discover ? await discoverHandlers(discover, { log: !!log }) : handlers;
|
|
72
|
+
const processedHandlers = allHandlers.map(processHandler).filter(Boolean);
|
|
73
|
+
setCachedHandlers(cacheKey, processedHandlers);
|
|
74
|
+
if (log && processedHandlers.length > 0) {
|
|
75
|
+
if (log === 'pretty') {
|
|
76
|
+
logger.info(`Discovered ${processedHandlers.length} handler${processedHandlers.length === 1 ? '' : 's'}:`);
|
|
77
|
+
for (const handler of processedHandlers) {
|
|
78
|
+
logger.info(` • ${handler.name}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// structured or true
|
|
83
|
+
logger.info('Handler discovery completed', {
|
|
84
|
+
handlerCount: processedHandlers.length,
|
|
85
|
+
handlers: processedHandlers.map((h) => h.name),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
logger.error('Failed to setup handlers:', error);
|
|
92
|
+
setCachedHandlers(cacheKey, []);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation utilities for CloudEvents processing
|
|
3
|
+
*/
|
|
4
|
+
import type { HandlerValidationError } from '../domain';
|
|
5
|
+
import type { ProcessedHandler } from './handler-cache';
|
|
6
|
+
/**
|
|
7
|
+
* Creates validation details from Zod error
|
|
8
|
+
*/
|
|
9
|
+
export declare const createValidationDetails: (error: {
|
|
10
|
+
issues: Array<{
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
path: Array<string | number>;
|
|
14
|
+
expected?: unknown;
|
|
15
|
+
received?: unknown;
|
|
16
|
+
}>;
|
|
17
|
+
}) => {
|
|
18
|
+
code: string;
|
|
19
|
+
message: string;
|
|
20
|
+
path: (string | number)[];
|
|
21
|
+
expected: string;
|
|
22
|
+
received: string;
|
|
23
|
+
}[];
|
|
24
|
+
/**
|
|
25
|
+
* Validates event data against handler schema
|
|
26
|
+
* Returns structured result for both normal and safeParse modes
|
|
27
|
+
*/
|
|
28
|
+
export declare const validateEventData: (handler: ProcessedHandler, eventData: unknown) => {
|
|
29
|
+
success: true;
|
|
30
|
+
} | {
|
|
31
|
+
error: HandlerValidationError;
|
|
32
|
+
shouldSkip: boolean;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Checks if handler matches event
|
|
36
|
+
*/
|
|
37
|
+
export declare const isHandlerMatching: (eventType: string, eventData: unknown, context: unknown) => (handler: ProcessedHandler) => boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Throws validation error in standard mode
|
|
40
|
+
*/
|
|
41
|
+
export declare const throwValidationError: (handlerType: string, error: HandlerValidationError) => never;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createValidationError } from '../infrastructure';
|
|
2
|
+
/**
|
|
3
|
+
* Creates validation details from Zod error
|
|
4
|
+
*/
|
|
5
|
+
export const createValidationDetails = (error) => error.issues.map((issue) => ({
|
|
6
|
+
code: issue.code,
|
|
7
|
+
message: issue.message,
|
|
8
|
+
path: issue.path,
|
|
9
|
+
expected: String(issue.expected),
|
|
10
|
+
received: String(issue.received),
|
|
11
|
+
}));
|
|
12
|
+
/**
|
|
13
|
+
* Validates event data against handler schema
|
|
14
|
+
* Returns structured result for both normal and safeParse modes
|
|
15
|
+
*/
|
|
16
|
+
export const validateEventData = (handler, eventData) => {
|
|
17
|
+
const result = handler.schema.safeParse(eventData);
|
|
18
|
+
if (!result.success) {
|
|
19
|
+
const validationDetails = createValidationDetails(result.error);
|
|
20
|
+
const handlerValidationError = {
|
|
21
|
+
handlerName: handler.name,
|
|
22
|
+
validationErrors: validationDetails,
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
error: handlerValidationError,
|
|
26
|
+
shouldSkip: handler.safeParse || false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { success: true };
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Checks if handler matches event
|
|
33
|
+
*/
|
|
34
|
+
export const isHandlerMatching = (eventType, eventData, context) => (handler) => {
|
|
35
|
+
if (handler.type !== eventType)
|
|
36
|
+
return false;
|
|
37
|
+
if (handler.match) {
|
|
38
|
+
const enrichedEvent = { data: eventData, ...context };
|
|
39
|
+
return handler.match(enrichedEvent);
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Throws validation error in standard mode
|
|
45
|
+
*/
|
|
46
|
+
export const throwValidationError = (handlerType, error) => {
|
|
47
|
+
throw createValidationError(handlerType, [error]);
|
|
48
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ZodTypeAny } from 'zod';
|
|
2
|
+
export interface PublishNatsEventOptions {
|
|
3
|
+
/**
|
|
4
|
+
* NATS URL(s), e.g., "nats://localhost:4222".
|
|
5
|
+
* Defaults to `process.env.NATS_URL` or `nats://localhost:4222`.
|
|
6
|
+
*/
|
|
7
|
+
servers?: string;
|
|
8
|
+
/**
|
|
9
|
+
* CloudEvent source identifier (e.g., "orderboss://orders-service").
|
|
10
|
+
*/
|
|
11
|
+
source?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Optional CloudEvent subject (e.g., an order ID). Not to be confused with the NATS subject.
|
|
14
|
+
*/
|
|
15
|
+
subject?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function publishNatsEvent<T extends ZodTypeAny>(subjectName: string, schema: T, eventData: unknown, options?: PublishNatsEventOptions): Promise<string>;
|
|
18
|
+
export declare function publishNatsRawEvent(subjectName: string, eventType: string, eventData: unknown, options?: PublishNatsEventOptions): Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* @internal Resets the cached NATS connection. Intended for testing only.
|
|
21
|
+
*/
|
|
22
|
+
export declare function __resetNatsPublisher(): void;
|