@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.
Files changed (58) hide show
  1. package/README.md +125 -0
  2. package/dist/src/adapters/cloudevents/cloudevents.d.ts +14 -0
  3. package/dist/src/adapters/cloudevents/cloudevents.js +58 -0
  4. package/dist/src/adapters/cloudevents/index.d.ts +8 -0
  5. package/dist/src/adapters/cloudevents/index.js +7 -0
  6. package/dist/src/adapters/cloudevents/parsers/binary-mode.d.ts +5 -0
  7. package/dist/src/adapters/cloudevents/parsers/binary-mode.js +32 -0
  8. package/dist/src/adapters/cloudevents/parsers/pubsub.d.ts +5 -0
  9. package/dist/src/adapters/cloudevents/parsers/pubsub.js +54 -0
  10. package/dist/src/adapters/cloudevents/parsers/raw-event.d.ts +5 -0
  11. package/dist/src/adapters/cloudevents/parsers/raw-event.js +17 -0
  12. package/dist/src/adapters/cloudevents/parsers/structured-mode.d.ts +5 -0
  13. package/dist/src/adapters/cloudevents/parsers/structured-mode.js +18 -0
  14. package/dist/src/adapters/cloudevents/types.d.ts +29 -0
  15. package/dist/src/adapters/cloudevents/types.js +1 -0
  16. package/dist/src/domain/discovery.d.ts +24 -0
  17. package/dist/src/domain/discovery.js +137 -0
  18. package/dist/src/domain/handler-factory.d.ts +24 -0
  19. package/dist/src/domain/handler-factory.js +62 -0
  20. package/dist/src/domain/index.d.ts +5 -0
  21. package/dist/src/domain/index.js +3 -0
  22. package/dist/src/domain/types.d.ts +52 -0
  23. package/dist/src/domain/types.js +6 -0
  24. package/dist/src/domain/validation.d.ts +37 -0
  25. package/dist/src/domain/validation.js +53 -0
  26. package/dist/src/index.d.ts +5 -0
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/infrastructure/errors.d.ts +53 -0
  29. package/dist/src/infrastructure/errors.js +54 -0
  30. package/dist/src/infrastructure/index.d.ts +4 -0
  31. package/dist/src/infrastructure/index.js +2 -0
  32. package/dist/src/infrastructure/logging.d.ts +18 -0
  33. package/dist/src/infrastructure/logging.js +27 -0
  34. package/dist/src/middlewares/cloudevents-middleware.d.ts +171 -0
  35. package/dist/src/middlewares/cloudevents-middleware.js +276 -0
  36. package/dist/src/middlewares/index.d.ts +1 -0
  37. package/dist/src/middlewares/index.js +1 -0
  38. package/dist/src/processing/dlq-safe.d.ts +34 -0
  39. package/dist/src/processing/dlq-safe.js +91 -0
  40. package/dist/src/processing/handler-cache.d.ts +36 -0
  41. package/dist/src/processing/handler-cache.js +94 -0
  42. package/dist/src/processing/index.d.ts +3 -0
  43. package/dist/src/processing/index.js +3 -0
  44. package/dist/src/processing/validation.d.ts +41 -0
  45. package/dist/src/processing/validation.js +48 -0
  46. package/dist/src/publishing/index.d.ts +2 -0
  47. package/dist/src/publishing/index.js +2 -0
  48. package/dist/src/publishing/nats.publisher.d.ts +22 -0
  49. package/dist/src/publishing/nats.publisher.js +66 -0
  50. package/dist/src/publishing/pubsub.publisher.d.ts +39 -0
  51. package/dist/src/publishing/pubsub.publisher.js +84 -0
  52. package/dist/src/transports/nats/index.d.ts +2 -0
  53. package/dist/src/transports/nats/index.js +2 -0
  54. package/dist/src/transports/nats/nats-consumer.d.ts +30 -0
  55. package/dist/src/transports/nats/nats-consumer.js +54 -0
  56. package/dist/src/transports/nats/nats-message-processor.d.ts +22 -0
  57. package/dist/src/transports/nats/nats-message-processor.js +95 -0
  58. package/package.json +46 -0
@@ -0,0 +1,66 @@
1
+ import { connect, StringCodec } from 'nats';
2
+ import { extractTypeFromSchema } from '../domain';
3
+ import { createValidationError, logger } from '../infrastructure';
4
+ const sc = StringCodec();
5
+ let natsConnectionPromise = null;
6
+ async function getNatsConnection(servers) {
7
+ if (!natsConnectionPromise) {
8
+ const url = servers ?? process.env.NATS_URL ?? 'nats://localhost:4222';
9
+ natsConnectionPromise = connect({ servers: url })
10
+ .then((connection) => {
11
+ logger.debug(`[NATS] connected to ${url}`);
12
+ return connection;
13
+ })
14
+ .catch((error) => {
15
+ logger.error('[NATS] connection error', error);
16
+ natsConnectionPromise = null;
17
+ throw error;
18
+ });
19
+ }
20
+ return natsConnectionPromise;
21
+ }
22
+ export async function publishNatsEvent(subjectName, schema, eventData, options) {
23
+ const eventType = extractTypeFromSchema(schema);
24
+ if (!eventType) {
25
+ throw new Error('Could not extract event type from schema. Make sure your schema has proper metadata.');
26
+ }
27
+ const validationResult = schema.safeParse(eventData);
28
+ if (!validationResult.success) {
29
+ const validationDetails = validationResult.error.issues.map((issue) => ({
30
+ code: issue.code,
31
+ message: issue.message,
32
+ path: issue.path,
33
+ expected: 'expected' in issue ? String(issue.expected) : undefined,
34
+ received: 'received' in issue ? String(issue.received) : undefined,
35
+ }));
36
+ const handlerValidationError = {
37
+ handlerName: `NatsPublisher:${eventType}`,
38
+ validationErrors: validationDetails,
39
+ };
40
+ throw createValidationError(eventType, [handlerValidationError]);
41
+ }
42
+ return publishNatsRawEvent(subjectName, eventType, validationResult.data, options);
43
+ }
44
+ export async function publishNatsRawEvent(subjectName, eventType, eventData, options) {
45
+ const cloudEvent = {
46
+ specversion: '1.0',
47
+ type: eventType,
48
+ source: options?.source || 'hono-service',
49
+ id: crypto.randomUUID(),
50
+ time: new Date().toISOString(),
51
+ datacontenttype: 'application/json',
52
+ data: eventData,
53
+ ...(options?.subject && { subject: options.subject }),
54
+ };
55
+ const data = JSON.stringify(cloudEvent);
56
+ const nc = await getNatsConnection(options?.servers);
57
+ nc.publish(subjectName, sc.encode(data));
58
+ logger.debug(`Published CloudEvent ${eventType} to NATS subject ${subjectName} (id=${cloudEvent.id})`);
59
+ return cloudEvent.id;
60
+ }
61
+ /**
62
+ * @internal Resets the cached NATS connection. Intended for testing only.
63
+ */
64
+ export function __resetNatsPublisher() {
65
+ natsConnectionPromise = null;
66
+ }
@@ -0,0 +1,39 @@
1
+ import type { ZodTypeAny } from 'zod';
2
+ export interface PublishEventOptions {
3
+ projectId?: string;
4
+ keyFilename?: string;
5
+ source?: string;
6
+ subject?: string;
7
+ attributes?: Record<string, string>;
8
+ }
9
+ /**
10
+ * Publishes an event using a Zod schema for validation and type extraction.
11
+ * Automatically extracts the event type from the schema and creates a CloudEvent.
12
+ *
13
+ * @param topicName - PubSub topic name
14
+ * @param schema - Zod schema that defines the event structure and type
15
+ * @param eventData - Event data that must match the schema
16
+ * @param options - Optional PubSub configuration
17
+ * @returns Promise resolving to the published message ID
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * await publishEvent(
22
+ * 'customer-events',
23
+ * CustomerCreatedSchema,
24
+ * { customer: { id: '123', email: 'test@example.com' } }
25
+ * )
26
+ * ```
27
+ */
28
+ export declare function publishEvent<T extends ZodTypeAny>(topicName: string, schema: T, eventData: unknown, options?: PublishEventOptions): Promise<string>;
29
+ /**
30
+ * Raw event publisher - bypasses schema validation.
31
+ * Use this when you need direct control over the event type and data.
32
+ *
33
+ * @param topicName - PubSub topic name
34
+ * @param eventType - Manual event type identifier
35
+ * @param eventData - Raw event data
36
+ * @param options - Optional PubSub configuration
37
+ * @returns Promise resolving to the published message ID
38
+ */
39
+ export declare function publishRawEvent(topicName: string, eventType: string, eventData: unknown, options?: PublishEventOptions): Promise<string>;
@@ -0,0 +1,84 @@
1
+ import { extractTypeFromSchema } from '../domain';
2
+ import { createValidationError, logger } from '../infrastructure';
3
+ /**
4
+ * Publishes an event using a Zod schema for validation and type extraction.
5
+ * Automatically extracts the event type from the schema and creates a CloudEvent.
6
+ *
7
+ * @param topicName - PubSub topic name
8
+ * @param schema - Zod schema that defines the event structure and type
9
+ * @param eventData - Event data that must match the schema
10
+ * @param options - Optional PubSub configuration
11
+ * @returns Promise resolving to the published message ID
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * await publishEvent(
16
+ * 'customer-events',
17
+ * CustomerCreatedSchema,
18
+ * { customer: { id: '123', email: 'test@example.com' } }
19
+ * )
20
+ * ```
21
+ */
22
+ export async function publishEvent(topicName, schema, eventData, options) {
23
+ // Extract event type from schema
24
+ const eventType = extractTypeFromSchema(schema);
25
+ if (!eventType) {
26
+ throw new Error('Could not extract event type from schema. Make sure your schema has proper metadata.');
27
+ }
28
+ // Validate the data against the schema
29
+ const validationResult = schema.safeParse(eventData);
30
+ if (!validationResult.success) {
31
+ const validationDetails = validationResult.error.issues.map((issue) => ({
32
+ code: issue.code,
33
+ message: issue.message,
34
+ path: issue.path,
35
+ expected: 'expected' in issue ? String(issue.expected) : undefined,
36
+ received: 'received' in issue ? String(issue.received) : undefined,
37
+ }));
38
+ const handlerValidationError = {
39
+ handlerName: `Publisher:${eventType}`,
40
+ validationErrors: validationDetails,
41
+ };
42
+ throw createValidationError(eventType, [handlerValidationError]);
43
+ }
44
+ return publishRawEvent(topicName, eventType, validationResult.data, options);
45
+ }
46
+ /**
47
+ * Raw event publisher - bypasses schema validation.
48
+ * Use this when you need direct control over the event type and data.
49
+ *
50
+ * @param topicName - PubSub topic name
51
+ * @param eventType - Manual event type identifier
52
+ * @param eventData - Raw event data
53
+ * @param options - Optional PubSub configuration
54
+ * @returns Promise resolving to the published message ID
55
+ */
56
+ export async function publishRawEvent(topicName, eventType, eventData, options) {
57
+ const { PubSub } = await import('@google-cloud/pubsub');
58
+ const pubsub = new PubSub({
59
+ projectId: options?.projectId,
60
+ keyFilename: options?.keyFilename,
61
+ });
62
+ const cloudEvent = {
63
+ specversion: '1.0',
64
+ type: eventType,
65
+ source: options?.source || 'hono-service',
66
+ id: crypto.randomUUID(),
67
+ time: new Date().toISOString(),
68
+ datacontenttype: 'application/json',
69
+ data: eventData,
70
+ ...(options?.subject && { subject: options.subject }),
71
+ };
72
+ const data = JSON.stringify(cloudEvent);
73
+ const dataBuffer = Buffer.from(data);
74
+ const topic = pubsub.topic(topicName);
75
+ const messageId = await topic.publishMessage({
76
+ data: dataBuffer,
77
+ attributes: {
78
+ 'content-type': 'application/cloudevents+json',
79
+ ...options?.attributes,
80
+ },
81
+ });
82
+ logger.debug(`Published CloudEvent ${eventType} with ID: ${messageId}`);
83
+ return messageId;
84
+ }
@@ -0,0 +1,2 @@
1
+ export * from './nats-consumer';
2
+ export * from './nats-message-processor';
@@ -0,0 +1,2 @@
1
+ export * from './nats-consumer';
2
+ export * from './nats-message-processor';
@@ -0,0 +1,30 @@
1
+ import { type Subscription } from 'nats';
2
+ import type { CloudEventsOptions } from '../../middlewares/cloudevents-middleware';
3
+ /**
4
+ * Describes the configuration required to bootstrap the NATS event consumer.
5
+ *
6
+ * @property servers - Optional NATS connection string; defaults to `NATS_URL` or the local instance.
7
+ * @property subject - NATS subject to subscribe to for incoming events.
8
+ * @property discover - Glob pattern or directory used to discover event handler classes.
9
+ * @property consumerName - Optional identifier appended to log output and the consumer name.
10
+ * @property quarantineTopic - Optional Pub/Sub topic for quarantining malformed messages when DLQ mode is enabled.
11
+ * @property errorTopic - Optional Pub/Sub topic for recovering handler errors when DLQ mode is enabled.
12
+ * @property projectId - Optional Google Cloud project identifier used for DLQ publishing.
13
+ * @property source - Optional CloudEvent source identifier applied to DLQ messages.
14
+ */
15
+ export interface NatsConsumerOptions extends Pick<CloudEventsOptions, 'quarantineTopic' | 'errorTopic' | 'projectId' | 'source'> {
16
+ servers?: string;
17
+ subject: string;
18
+ discover: string;
19
+ consumerName?: string;
20
+ }
21
+ /**
22
+ * Connects to NATS, discovers matching event handlers, and processes incoming CloudEvents.
23
+ *
24
+ * @param options - Consumer configuration describing connection, discovery, and subscription details.
25
+ * @returns The active NATS subscription for the configured subject.
26
+ *
27
+ * When `quarantineTopic` or `errorTopic` is provided, the consumer forwards malformed messages
28
+ * and handler failures to the configured DLQ topics instead of throwing errors.
29
+ */
30
+ export declare function consumeNatsEvents(options: NatsConsumerOptions): Promise<Subscription>;
@@ -0,0 +1,54 @@
1
+ import { connect, StringCodec } from 'nats';
2
+ import { discoverHandlers } from '../../domain';
3
+ import { logger } from '../../infrastructure/logging';
4
+ import { processHandler } from '../../processing/handler-cache';
5
+ import { createNatsMessageProcessor } from './nats-message-processor';
6
+ const sc = StringCodec();
7
+ /**
8
+ * Connects to NATS, discovers matching event handlers, and processes incoming CloudEvents.
9
+ *
10
+ * @param options - Consumer configuration describing connection, discovery, and subscription details.
11
+ * @returns The active NATS subscription for the configured subject.
12
+ *
13
+ * When `quarantineTopic` or `errorTopic` is provided, the consumer forwards malformed messages
14
+ * and handler failures to the configured DLQ topics instead of throwing errors.
15
+ */
16
+ export async function consumeNatsEvents(options) {
17
+ const servers = options.servers ?? process.env.NATS_URL ?? 'nats://localhost:4222';
18
+ const subject = options.subject;
19
+ const name = options.consumerName ?? `nats-consumer:${subject}`;
20
+ // 1) Discover handler classes from *.event.ts files
21
+ const handlerConstructors = await discoverHandlers(options.discover);
22
+ const processedHandlers = handlerConstructors
23
+ .map(processHandler)
24
+ .filter((h) => h !== null);
25
+ logger.info(`[${name}] discovered handlers`, {
26
+ count: processedHandlers.length,
27
+ handlers: processedHandlers.map((h) => h.name),
28
+ });
29
+ // 2) Connect to NATS
30
+ const nc = await connect({ servers });
31
+ logger.info(`[${name}] connected to NATS: ${servers}`);
32
+ // 3) Subscribe to the subject
33
+ const sub = nc.subscribe(subject);
34
+ logger.info(`[${name}] subscribed to subject: ${subject}`);
35
+ const dlqEnabled = Boolean(options.quarantineTopic || options.errorTopic);
36
+ const { handleMessage, handleUnhandledProcessingError } = createNatsMessageProcessor({
37
+ name,
38
+ subject,
39
+ dlqEnabled,
40
+ options,
41
+ processedHandlers,
42
+ decode: (data) => sc.decode(data),
43
+ logger,
44
+ });
45
+ const processSubscription = async () => {
46
+ for await (const msg of sub) {
47
+ await handleMessage(msg).catch((error) => handleUnhandledProcessingError(msg, error));
48
+ }
49
+ };
50
+ processSubscription().catch((err) => {
51
+ logger.error(`[${name}] subscription loop crashed`, err);
52
+ });
53
+ return sub;
54
+ }
@@ -0,0 +1,22 @@
1
+ import type { Msg } from 'nats';
2
+ import { type DlqOptions } from '../../processing/dlq-safe';
3
+ import type { ProcessedHandler } from '../../processing/handler-cache';
4
+ export interface LoggerLike {
5
+ info(message: string, meta?: unknown): void;
6
+ warn(message: string, meta?: unknown): void;
7
+ error(message: string, meta?: unknown): void;
8
+ }
9
+ export interface NatsMessageProcessorDeps {
10
+ name: string;
11
+ subject: string;
12
+ dlqEnabled: boolean;
13
+ options: DlqOptions;
14
+ processedHandlers: ProcessedHandler[];
15
+ decode: (data: Uint8Array) => string;
16
+ logger: LoggerLike;
17
+ }
18
+ export interface NatsMessageProcessor {
19
+ handleMessage(msg: Msg): Promise<void>;
20
+ handleUnhandledProcessingError(msg: Msg, error: unknown): Promise<void>;
21
+ }
22
+ export declare const createNatsMessageProcessor: ({ name, subject, dlqEnabled, options, processedHandlers, decode, logger, }: NatsMessageProcessorDeps) => NatsMessageProcessor;
@@ -0,0 +1,95 @@
1
+ import { createProcessingContext, publishRecoverableError, quarantineMessage, } from '../../processing/dlq-safe';
2
+ import { throwValidationError, validateEventData } from '../../processing/validation';
3
+ export const createNatsMessageProcessor = ({ name, subject, dlqEnabled, options, processedHandlers, decode, logger, }) => {
4
+ const toEnrichedEvent = (ce) => ({
5
+ eventType: ce.type,
6
+ source: ce.source,
7
+ subject: ce.subject,
8
+ time: ce.time ?? new Date().toISOString(),
9
+ messageId: ce.id,
10
+ data: ce.data,
11
+ });
12
+ const toUnknownContext = (msg) => ({
13
+ eventType: 'unknown',
14
+ source: `nats://${subject}`,
15
+ subject,
16
+ time: new Date().toISOString(),
17
+ messageId: msg.headers?.get('Nats-Msg-Id') ?? 'unknown',
18
+ data: decode(msg.data),
19
+ });
20
+ const createContext = (event, ce) => createProcessingContext(event.eventType, event.data, event, ce);
21
+ const safeParseMessage = (msg) => {
22
+ try {
23
+ const cloudEvent = JSON.parse(decode(msg.data));
24
+ return { ok: true, cloudEvent, enriched: toEnrichedEvent(cloudEvent) };
25
+ }
26
+ catch (error) {
27
+ return { ok: false, error, context: createContext(toUnknownContext(msg)) };
28
+ }
29
+ };
30
+ const findHandler = (event) => processedHandlers.find((handler) => handler.type === event.eventType && (!handler.match || handler.match(event)));
31
+ const handleParseFailure = async ({ context, error }) => {
32
+ logger.error(`[${name}] failed to parse CloudEvent payload`, error);
33
+ if (!dlqEnabled)
34
+ return;
35
+ await quarantineMessage(context, 'parse_error', options, error);
36
+ };
37
+ const handleMissingHandler = async (context, eventType) => {
38
+ logger.warn(`[${name}] no handler for event type ${eventType}`);
39
+ if (!dlqEnabled)
40
+ return;
41
+ await quarantineMessage(context, 'no_handler', options, new Error(`No handler for event type ${eventType}`));
42
+ };
43
+ const handleValidationFailure = async (validationResult, handler, context) => {
44
+ if (dlqEnabled) {
45
+ await quarantineMessage(context, 'validation_error', options, validationResult.error);
46
+ return;
47
+ }
48
+ if (validationResult.shouldSkip)
49
+ return;
50
+ throwValidationError(handler.name, validationResult.error);
51
+ };
52
+ const executeHandler = async (handler, enriched, context) => {
53
+ try {
54
+ await handler.handle(enriched.data, enriched);
55
+ }
56
+ catch (error) {
57
+ if (!dlqEnabled)
58
+ throw error;
59
+ await publishRecoverableError(context, error, options);
60
+ }
61
+ };
62
+ const handleMessage = async (msg) => {
63
+ const parseResult = safeParseMessage(msg);
64
+ if (!parseResult.ok) {
65
+ await handleParseFailure(parseResult);
66
+ return;
67
+ }
68
+ const { cloudEvent, enriched } = parseResult;
69
+ const processingContext = createContext(enriched, cloudEvent);
70
+ const handler = findHandler(enriched);
71
+ if (!handler) {
72
+ await handleMissingHandler(processingContext, enriched.eventType);
73
+ return;
74
+ }
75
+ const validationResult = validateEventData(handler, enriched.data);
76
+ if ('error' in validationResult) {
77
+ await handleValidationFailure(validationResult, handler, processingContext);
78
+ return;
79
+ }
80
+ await executeHandler(handler, enriched, processingContext);
81
+ };
82
+ const handleUnhandledProcessingError = async (msg, error) => {
83
+ logger.error(`[${name}] failed to handle NATS message`, error);
84
+ if (!dlqEnabled) {
85
+ return;
86
+ }
87
+ try {
88
+ await quarantineMessage(createContext(toUnknownContext(msg)), 'unhandled_error', options, error);
89
+ }
90
+ catch (quarantineError) {
91
+ logger.error(`[${name}] failed to quarantine unhandled error`, quarantineError);
92
+ }
93
+ };
94
+ return { handleMessage, handleUnhandledProcessingError };
95
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@crossdelta/cloudevents",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "dist/src/index.js",
6
+ "types": "dist/src/index.d.ts",
7
+ "files": [
8
+ "dist/src"
9
+ ],
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/src/index.js",
13
+ "types": "./dist/src/index.d.ts"
14
+ }
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "start:dev": "tsc -p tsconfig.json --watch",
22
+ "stryker": "bun stryker run",
23
+ "lint": "biome lint --fix",
24
+ "test": "bun test",
25
+ "prepublishOnly": "bun run build"
26
+ },
27
+ "dependencies": {
28
+ "cloudevents": "7.0.2",
29
+ "glob": "11.0.0",
30
+ "nats": "^2.29.3"
31
+ },
32
+ "optionalDependencies": {
33
+ "@google-cloud/pubsub": "5.2.0"
34
+ },
35
+ "peerDependencies": {
36
+ "hono": "^4.6.0",
37
+ "zod": "^3.23.0"
38
+ },
39
+ "trustedDependencies": [],
40
+ "devDependencies": {
41
+ "@types/bun": "^1.3.3",
42
+ "@stryker-mutator/core": "^9.1.1",
43
+ "stryker-mutator-bun-runner": "^0.4.0",
44
+ "typescript": "^5.9.2"
45
+ }
46
+ }