@crossdelta/cloudevents 0.5.3 → 0.5.5
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 +24 -6
- package/dist/publishing/nats.publisher.d.ts +11 -51
- package/dist/publishing/nats.publisher.js +68 -82
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Type-safe event-driven microservices with [NATS](https://nats.io) and [Zod](http
|
|
|
11
11
|
└──────────────┘ │ │ └──────────────┘
|
|
12
12
|
└──────────────┘
|
|
13
13
|
│ │
|
|
14
|
-
│
|
|
14
|
+
│ publish(...) │ handleEvent(...)
|
|
15
15
|
▼ ▼
|
|
16
16
|
┌──────────────┐ ┌──────────────┐
|
|
17
17
|
│ { orderId, │ │ Zod schema │
|
|
@@ -286,14 +286,32 @@ export const OrdersCreatedContract = createContract({
|
|
|
286
286
|
```typescript
|
|
287
287
|
interface ChannelConfig {
|
|
288
288
|
stream: string // JetStream stream name (e.g., 'ORDERS')
|
|
289
|
-
subject?: string // NATS subject (
|
|
289
|
+
subject?: string // NATS subject (optional override)
|
|
290
290
|
}
|
|
291
291
|
```
|
|
292
292
|
|
|
293
|
-
**Subject
|
|
294
|
-
-
|
|
295
|
-
-
|
|
296
|
-
-
|
|
293
|
+
**Subject Routing (CRITICAL):**
|
|
294
|
+
- **Without `subject`**: Domain is auto-pluralized: `customer.created` → subject `customers.created`
|
|
295
|
+
- **With `subject`**: Uses exact subject specified (no auto-pluralization)
|
|
296
|
+
- **Why pluralize**: Stream subjects are plural (`customers.*`), event types are singular (`customer.created`)
|
|
297
|
+
|
|
298
|
+
**Examples:**
|
|
299
|
+
```typescript
|
|
300
|
+
// Auto-pluralized subject
|
|
301
|
+
createContract({
|
|
302
|
+
type: 'order.created', // Event type: singular
|
|
303
|
+
channel: { stream: 'ORDERS' }, // Subject: orders.created (plural)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// Custom subject (no auto-pluralization)
|
|
307
|
+
createContract({
|
|
308
|
+
type: 'order.created',
|
|
309
|
+
channel: {
|
|
310
|
+
stream: 'ORDERS',
|
|
311
|
+
subject: 'orders.v1.created' // Exact subject, no auto-pluralization
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
```
|
|
297
315
|
|
|
298
316
|
### Multiple Events, Same Stream
|
|
299
317
|
|
|
@@ -1,59 +1,19 @@
|
|
|
1
1
|
import type { ZodTypeAny } from 'zod';
|
|
2
2
|
import { type RoutingConfig } from '../domain';
|
|
3
3
|
export interface PublishNatsEventOptions {
|
|
4
|
-
/**
|
|
5
|
-
* NATS URL(s), e.g., "nats://localhost:4222".
|
|
6
|
-
* Defaults to `process.env.NATS_URL` or `nats://localhost:4222`.
|
|
7
|
-
*/
|
|
8
4
|
servers?: string;
|
|
9
|
-
/**
|
|
10
|
-
* CloudEvent source identifier (e.g., "orderboss://orders-service").
|
|
11
|
-
*/
|
|
12
5
|
source?: string;
|
|
13
|
-
/**
|
|
14
|
-
* Optional CloudEvent subject (e.g., an order ID). Not to be confused with the NATS subject.
|
|
15
|
-
*/
|
|
16
6
|
subject?: string;
|
|
17
|
-
/**
|
|
18
|
-
* Tenant identifier for multi-tenant event routing.
|
|
19
|
-
* Will be added as a CloudEvent extension attribute.
|
|
20
|
-
*/
|
|
21
7
|
tenantId?: string;
|
|
22
8
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
* // Returns: 'orders.created'
|
|
35
|
-
*
|
|
36
|
-
* deriveSubjectFromType('unknown.event.type', config)
|
|
37
|
-
* // Returns: 'events.unknown.event.type'
|
|
38
|
-
* ```
|
|
39
|
-
*/
|
|
40
|
-
export declare function deriveSubjectFromType(eventType: string, config?: RoutingConfig): string;
|
|
41
|
-
/**
|
|
42
|
-
* Derives a JetStream stream name from a CloudEvent type using routing configuration.
|
|
43
|
-
*/
|
|
44
|
-
export declare function deriveStreamFromType(eventType: string, config?: RoutingConfig): string | undefined;
|
|
45
|
-
export declare function publishNatsEvent<T extends ZodTypeAny>(subjectName: string, schema: T, eventData: unknown, options?: PublishNatsEventOptions): Promise<string>;
|
|
46
|
-
export declare function publishNatsRawEvent(subjectName: string, eventType: string, eventData: unknown, options?: PublishNatsEventOptions): Promise<string>;
|
|
47
|
-
/**
|
|
48
|
-
* Simplified publish function where subject equals event type.
|
|
49
|
-
*
|
|
50
|
-
* @example
|
|
51
|
-
* ```typescript
|
|
52
|
-
* await publish('orders.created', { orderId: '123', total: 99.99 })
|
|
53
|
-
* ```
|
|
54
|
-
*/
|
|
55
|
-
export declare function publish(eventType: string, eventData: unknown, options?: PublishNatsEventOptions): Promise<string>;
|
|
56
|
-
/**
|
|
57
|
-
* @internal Resets the cached NATS connection. Intended for testing only.
|
|
58
|
-
*/
|
|
59
|
-
export declare function __resetNatsPublisher(): void;
|
|
9
|
+
export declare const deriveSubjectFromType: (eventType: string, config?: RoutingConfig) => string;
|
|
10
|
+
export declare const deriveStreamFromType: (eventType: string, config?: RoutingConfig) => string | undefined;
|
|
11
|
+
export declare const publishNatsRawEvent: (subjectName: string, eventType: string, eventData: unknown, options?: PublishNatsEventOptions) => Promise<string>;
|
|
12
|
+
export declare const publishNatsEvent: <T extends ZodTypeAny>(subjectName: string, schema: T, eventData: unknown, options?: PublishNatsEventOptions) => Promise<string>;
|
|
13
|
+
export declare const publish: (eventTypeOrContract: string | {
|
|
14
|
+
type: string;
|
|
15
|
+
channel?: {
|
|
16
|
+
subject?: string;
|
|
17
|
+
};
|
|
18
|
+
}, eventData: unknown, options?: PublishNatsEventOptions) => Promise<string>;
|
|
19
|
+
export declare const __resetNatsPublisher: () => void;
|
|
@@ -2,49 +2,59 @@ import { connect, StringCodec } from 'nats';
|
|
|
2
2
|
import { extractTypeFromSchema } from '../domain';
|
|
3
3
|
import { createValidationError, logger } from '../infrastructure';
|
|
4
4
|
const sc = StringCodec();
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
5
|
+
let natsConnectionPromise = null;
|
|
6
|
+
const pluralize = (word) => {
|
|
7
|
+
if (word.endsWith('y') && !['a', 'e', 'i', 'o', 'u'].includes(word[word.length - 2])) {
|
|
8
|
+
return `${word.slice(0, -1)}ies`;
|
|
9
|
+
}
|
|
10
|
+
if (word.endsWith('s') || word.endsWith('sh') || word.endsWith('ch') || word.endsWith('x')) {
|
|
11
|
+
return `${word}es`;
|
|
12
|
+
}
|
|
13
|
+
return `${word}s`;
|
|
14
|
+
};
|
|
15
|
+
const deriveSubjectFromEventType = (eventType) => {
|
|
16
|
+
const parts = eventType.split('.');
|
|
17
|
+
if (parts.length < 2)
|
|
18
|
+
return eventType;
|
|
19
|
+
const domain = parts[0];
|
|
20
|
+
const action = parts.slice(1).join('.');
|
|
21
|
+
const pluralDomain = pluralize(domain);
|
|
22
|
+
return `${pluralDomain}.${action}`;
|
|
23
|
+
};
|
|
24
|
+
const getNatsConnection = async (servers) => {
|
|
25
|
+
if (!natsConnectionPromise) {
|
|
26
|
+
const url = servers ?? process.env.NATS_URL ?? 'nats://localhost:4222';
|
|
27
|
+
natsConnectionPromise = connect({ servers: url })
|
|
28
|
+
.then((connection) => {
|
|
29
|
+
logger.debug(`[NATS] connected to ${url}`);
|
|
30
|
+
return connection;
|
|
31
|
+
})
|
|
32
|
+
.catch((error) => {
|
|
33
|
+
logger.error('[NATS] connection error', error);
|
|
34
|
+
natsConnectionPromise = null;
|
|
35
|
+
throw error;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return natsConnectionPromise;
|
|
39
|
+
};
|
|
40
|
+
export const deriveSubjectFromType = (eventType, config) => {
|
|
23
41
|
if (!config?.typeToSubjectMap) {
|
|
24
|
-
// No mapping configured, use type as-is (replace dots with dots is a no-op)
|
|
25
42
|
return config?.defaultSubjectPrefix ? `${config.defaultSubjectPrefix}.${eventType}` : eventType;
|
|
26
43
|
}
|
|
27
|
-
// Find the longest matching prefix
|
|
28
44
|
const sortedPrefixes = Object.keys(config.typeToSubjectMap).sort((a, b) => b.length - a.length);
|
|
29
45
|
for (const prefix of sortedPrefixes) {
|
|
30
46
|
if (eventType.startsWith(prefix)) {
|
|
31
47
|
const suffix = eventType.slice(prefix.length);
|
|
32
48
|
const mappedPrefix = config.typeToSubjectMap[prefix];
|
|
33
|
-
// Remove leading dot from suffix if present
|
|
34
49
|
const cleanSuffix = suffix.startsWith('.') ? suffix.slice(1) : suffix;
|
|
35
50
|
return cleanSuffix ? `${mappedPrefix}.${cleanSuffix}` : mappedPrefix;
|
|
36
51
|
}
|
|
37
52
|
}
|
|
38
|
-
// No match found, use default prefix or type as-is
|
|
39
53
|
return config.defaultSubjectPrefix ? `${config.defaultSubjectPrefix}.${eventType}` : eventType;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
* Derives a JetStream stream name from a CloudEvent type using routing configuration.
|
|
43
|
-
*/
|
|
44
|
-
export function deriveStreamFromType(eventType, config) {
|
|
54
|
+
};
|
|
55
|
+
export const deriveStreamFromType = (eventType, config) => {
|
|
45
56
|
if (!config?.typeToStreamMap)
|
|
46
57
|
return undefined;
|
|
47
|
-
// Find the longest matching prefix
|
|
48
58
|
const sortedPrefixes = Object.keys(config.typeToStreamMap).sort((a, b) => b.length - a.length);
|
|
49
59
|
for (const prefix of sortedPrefixes) {
|
|
50
60
|
if (eventType.startsWith(prefix)) {
|
|
@@ -52,25 +62,26 @@ export function deriveStreamFromType(eventType, config) {
|
|
|
52
62
|
}
|
|
53
63
|
}
|
|
54
64
|
return undefined;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
};
|
|
66
|
+
export const publishNatsRawEvent = async (subjectName, eventType, eventData, options) => {
|
|
67
|
+
const cloudEvent = {
|
|
68
|
+
specversion: '1.0',
|
|
69
|
+
type: eventType,
|
|
70
|
+
source: options?.source || 'hono-service',
|
|
71
|
+
id: crypto.randomUUID(),
|
|
72
|
+
time: new Date().toISOString(),
|
|
73
|
+
datacontenttype: 'application/json',
|
|
74
|
+
data: eventData,
|
|
75
|
+
...(options?.subject && { subject: options.subject }),
|
|
76
|
+
...(options?.tenantId && { tenantid: options.tenantId }),
|
|
77
|
+
};
|
|
78
|
+
const data = JSON.stringify(cloudEvent);
|
|
79
|
+
const nc = await getNatsConnection(options?.servers);
|
|
80
|
+
nc.publish(subjectName, sc.encode(data));
|
|
81
|
+
logger.debug(`Published CloudEvent ${eventType} to NATS subject ${subjectName} (id=${cloudEvent.id})`);
|
|
82
|
+
return cloudEvent.id;
|
|
83
|
+
};
|
|
84
|
+
export const publishNatsEvent = async (subjectName, schema, eventData, options) => {
|
|
74
85
|
const eventType = extractTypeFromSchema(schema);
|
|
75
86
|
if (!eventType) {
|
|
76
87
|
throw new Error('Could not extract event type from schema. Make sure your schema has proper metadata.');
|
|
@@ -91,39 +102,14 @@ export async function publishNatsEvent(subjectName, schema, eventData, options)
|
|
|
91
102
|
throw createValidationError(eventType, [handlerValidationError]);
|
|
92
103
|
}
|
|
93
104
|
return publishNatsRawEvent(subjectName, eventType, validationResult.data, options);
|
|
94
|
-
}
|
|
95
|
-
export async
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
data: eventData,
|
|
104
|
-
...(options?.subject && { subject: options.subject }),
|
|
105
|
-
...(options?.tenantId && { tenantid: options.tenantId }), // CloudEvents extension (lowercase)
|
|
106
|
-
};
|
|
107
|
-
const data = JSON.stringify(cloudEvent);
|
|
108
|
-
const nc = await getNatsConnection(options?.servers);
|
|
109
|
-
nc.publish(subjectName, sc.encode(data));
|
|
110
|
-
logger.debug(`Published CloudEvent ${eventType} to NATS subject ${subjectName} (id=${cloudEvent.id})`);
|
|
111
|
-
return cloudEvent.id;
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Simplified publish function where subject equals event type.
|
|
115
|
-
*
|
|
116
|
-
* @example
|
|
117
|
-
* ```typescript
|
|
118
|
-
* await publish('orders.created', { orderId: '123', total: 99.99 })
|
|
119
|
-
* ```
|
|
120
|
-
*/
|
|
121
|
-
export async function publish(eventType, eventData, options) {
|
|
122
|
-
return publishNatsRawEvent(eventType, eventType, eventData, options);
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* @internal Resets the cached NATS connection. Intended for testing only.
|
|
126
|
-
*/
|
|
127
|
-
export function __resetNatsPublisher() {
|
|
105
|
+
};
|
|
106
|
+
export const publish = async (eventTypeOrContract, eventData, options) => {
|
|
107
|
+
const eventType = typeof eventTypeOrContract === 'string' ? eventTypeOrContract : eventTypeOrContract.type;
|
|
108
|
+
const natsSubject = typeof eventTypeOrContract === 'string'
|
|
109
|
+
? deriveSubjectFromEventType(eventTypeOrContract)
|
|
110
|
+
: (eventTypeOrContract.channel?.subject ?? eventTypeOrContract.type);
|
|
111
|
+
return publishNatsRawEvent(natsSubject, eventType, eventData, options);
|
|
112
|
+
};
|
|
113
|
+
export const __resetNatsPublisher = () => {
|
|
128
114
|
natsConnectionPromise = null;
|
|
129
|
-
}
|
|
115
|
+
};
|