@crossdelta/cloudevents 0.5.2 → 0.5.4
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 +149 -26
- package/dist/domain/contract-helper.d.ts +35 -6
- package/dist/domain/contract-helper.js +37 -5
- package/dist/domain/discovery.d.ts +3 -3
- package/dist/domain/discovery.js +3 -3
- package/dist/domain/index.d.ts +2 -2
- package/dist/domain/index.js +1 -1
- package/dist/domain/types.d.ts +20 -0
- package/dist/index.d.ts +1 -1
- package/dist/processing/idempotency.d.ts +1 -1
- package/dist/processing/idempotency.js +1 -1
- package/dist/publishing/nats.publisher.d.ts +11 -51
- package/dist/publishing/nats.publisher.js +68 -82
- package/dist/transports/nats/jetstream-consumer.d.ts +2 -2
- package/dist/transports/nats/jetstream-consumer.js +2 -2
- package/dist/transports/nats/nats-consumer.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -29,7 +29,11 @@ bun add @crossdelta/cloudevents zod@4
|
|
|
29
29
|
|
|
30
30
|
## Quick Start
|
|
31
31
|
|
|
32
|
-
**
|
|
32
|
+
> **Note:** Services **never create streams**. Streams must exist before services consume from them:
|
|
33
|
+
> - **Development**: Use [NATS CLI](https://docs.nats.io/running-a-nats-service/nats_admin/jetstream_admin/streams) or [Platform SDK](https://www.npmjs.com/package/@crossdelta/platform-sdk) (`pf dev` auto-creates from contracts)
|
|
34
|
+
> - **Production**: Use infrastructure-as-code (Pulumi, Terraform) or the [Stream Setup](#stream-setup) function below
|
|
35
|
+
|
|
36
|
+
**1. Create an event handler** (`src/events/orders-created.handler.ts`):
|
|
33
37
|
|
|
34
38
|
```typescript
|
|
35
39
|
import { handleEvent } from '@crossdelta/cloudevents'
|
|
@@ -55,17 +59,7 @@ export default handleEvent(
|
|
|
55
59
|
)
|
|
56
60
|
```
|
|
57
61
|
|
|
58
|
-
**2.
|
|
59
|
-
|
|
60
|
-
```typescript
|
|
61
|
-
import { ensureJetStreams } from '@crossdelta/cloudevents'
|
|
62
|
-
|
|
63
|
-
await ensureJetStreams({
|
|
64
|
-
streams: [{ stream: 'ORDERS', subjects: ['orders.*'] }],
|
|
65
|
-
})
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
**3. Start consuming:**
|
|
62
|
+
**2. Start consuming:**
|
|
69
63
|
|
|
70
64
|
```typescript
|
|
71
65
|
import { consumeJetStreams } from '@crossdelta/cloudevents'
|
|
@@ -73,11 +67,11 @@ import { consumeJetStreams } from '@crossdelta/cloudevents'
|
|
|
73
67
|
consumeJetStreams({
|
|
74
68
|
streams: ['ORDERS'],
|
|
75
69
|
consumer: 'my-service',
|
|
76
|
-
discover: './src/events/**/*.
|
|
70
|
+
discover: './src/events/**/*.handler.ts',
|
|
77
71
|
})
|
|
78
72
|
```
|
|
79
73
|
|
|
80
|
-
**
|
|
74
|
+
**3. Publish from another service:**
|
|
81
75
|
|
|
82
76
|
```typescript
|
|
83
77
|
import { publish } from '@crossdelta/cloudevents'
|
|
@@ -87,6 +81,10 @@ await publish('orders.created', { orderId: 'ord_123', total: 99.99 })
|
|
|
87
81
|
|
|
88
82
|
That's it. Handlers are auto-discovered, validated with Zod, and messages persist in JetStream.
|
|
89
83
|
|
|
84
|
+
> **Stream creation** is handled by your development environment or infrastructure — not by services:
|
|
85
|
+
> - Development: [NATS CLI](https://docs.nats.io/running-a-nats-service/nats_admin/jetstream_admin/streams), [@crossdelta/platform-sdk](https://www.npmjs.com/package/@crossdelta/platform-sdk), or manual setup
|
|
86
|
+
> - Production: See [Stream Setup](#stream-setup) below for `ensureJetStreams()` usage in infrastructure code
|
|
87
|
+
|
|
90
88
|
---
|
|
91
89
|
|
|
92
90
|
## Why use this?
|
|
@@ -125,10 +123,10 @@ export default handleEvent(
|
|
|
125
123
|
|
|
126
124
|
### Handlers
|
|
127
125
|
|
|
128
|
-
Drop a `*.
|
|
126
|
+
Drop a `*.handler.ts` file anywhere — it's auto-registered:
|
|
129
127
|
|
|
130
128
|
```typescript
|
|
131
|
-
// src/events/user-signup.
|
|
129
|
+
// src/events/user-signup.handler.ts
|
|
132
130
|
import { z } from 'zod'
|
|
133
131
|
|
|
134
132
|
const UserSignupSchema = z.object({
|
|
@@ -158,7 +156,9 @@ await publish('orders.created', orderData)
|
|
|
158
156
|
|
|
159
157
|
### Stream Setup
|
|
160
158
|
|
|
161
|
-
|
|
159
|
+
> **For infrastructure use only** - Services should never call this function.
|
|
160
|
+
|
|
161
|
+
Create streams during infrastructure setup (Pulumi, deployment scripts):
|
|
162
162
|
|
|
163
163
|
```typescript
|
|
164
164
|
import { ensureJetStreams } from '@crossdelta/cloudevents'
|
|
@@ -175,6 +175,7 @@ await ensureJetStreams({
|
|
|
175
175
|
},
|
|
176
176
|
],
|
|
177
177
|
})
|
|
178
|
+
})
|
|
178
179
|
```
|
|
179
180
|
|
|
180
181
|
### Consuming
|
|
@@ -186,13 +187,13 @@ import { consumeJetStreams } from '@crossdelta/cloudevents'
|
|
|
186
187
|
consumeJetStreams({
|
|
187
188
|
streams: ['ORDERS'],
|
|
188
189
|
consumer: 'billing',
|
|
189
|
-
discover: './src/events/**/*.
|
|
190
|
+
discover: './src/events/**/*.handler.ts',
|
|
190
191
|
})
|
|
191
192
|
|
|
192
193
|
// Core NATS — fire-and-forget, simpler
|
|
193
194
|
await consumeNatsEvents({
|
|
194
195
|
subjects: ['notifications.*'],
|
|
195
|
-
discover: './src/events/**/*.
|
|
196
|
+
discover: './src/events/**/*.handler.ts',
|
|
196
197
|
})
|
|
197
198
|
```
|
|
198
199
|
|
|
@@ -239,7 +240,7 @@ consumeJetStreams({
|
|
|
239
240
|
// Required
|
|
240
241
|
streams: ['ORDERS'],
|
|
241
242
|
consumer: 'my-service',
|
|
242
|
-
discover: './src/events/**/*.
|
|
243
|
+
discover: './src/events/**/*.handler.ts',
|
|
243
244
|
|
|
244
245
|
// Optional
|
|
245
246
|
servers: 'nats://localhost:4222',
|
|
@@ -254,6 +255,129 @@ consumeJetStreams({
|
|
|
254
255
|
|
|
255
256
|
## Advanced Features
|
|
256
257
|
|
|
258
|
+
<details>
|
|
259
|
+
<parameter name="summary"><b>Channel Metadata (Infrastructure Integration)</b></summary>
|
|
260
|
+
|
|
261
|
+
<br />
|
|
262
|
+
|
|
263
|
+
Contracts can include **channel metadata** to define which NATS JetStream stream they belong to. This enables infrastructure-as-code workflows where streams are materialized from contracts.
|
|
264
|
+
|
|
265
|
+
### Basic Contract with Channel
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
import { createContract } from '@crossdelta/cloudevents'
|
|
269
|
+
import { z } from 'zod'
|
|
270
|
+
|
|
271
|
+
export const OrdersCreatedContract = createContract({
|
|
272
|
+
type: 'orders.created',
|
|
273
|
+
channel: {
|
|
274
|
+
stream: 'ORDERS',
|
|
275
|
+
// subject defaults to 'orders.created' if omitted
|
|
276
|
+
},
|
|
277
|
+
schema: z.object({
|
|
278
|
+
orderId: z.string(),
|
|
279
|
+
total: z.number(),
|
|
280
|
+
}),
|
|
281
|
+
})
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Channel Configuration
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
interface ChannelConfig {
|
|
288
|
+
stream: string // JetStream stream name (e.g., 'ORDERS')
|
|
289
|
+
subject?: string // NATS subject (defaults to event type)
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Subject Defaulting:**
|
|
294
|
+
- If `subject` is **not provided**, it defaults to the event `type`
|
|
295
|
+
- This follows the convention: `orders.created` → subject `orders.created`
|
|
296
|
+
- Override when needed: `subject: 'orders.v1.created'`
|
|
297
|
+
|
|
298
|
+
### Multiple Events, Same Stream
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
// orders.created → ORDERS stream
|
|
302
|
+
export const OrdersCreatedContract = createContract({
|
|
303
|
+
type: 'orders.created',
|
|
304
|
+
channel: { stream: 'ORDERS' },
|
|
305
|
+
schema: OrderCreatedSchema,
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// orders.updated → ORDERS stream (same stream!)
|
|
309
|
+
export const OrdersUpdatedContract = createContract({
|
|
310
|
+
type: 'orders.updated',
|
|
311
|
+
channel: { stream: 'ORDERS' },
|
|
312
|
+
schema: OrderUpdatedSchema,
|
|
313
|
+
})
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Result:** Both events share the `ORDERS` stream with subjects `orders.created` and `orders.updated`.
|
|
317
|
+
|
|
318
|
+
### Infrastructure Materialization
|
|
319
|
+
|
|
320
|
+
Channel metadata is **conceptual** - it defines routing, not infrastructure policy.
|
|
321
|
+
|
|
322
|
+
**Contracts define:**
|
|
323
|
+
- Event type (`orders.created`)
|
|
324
|
+
- Stream routing (`ORDERS`)
|
|
325
|
+
- Subject mapping (`orders.created`)
|
|
326
|
+
|
|
327
|
+
**Infrastructure defines:**
|
|
328
|
+
- Retention (7 days, 14 days, etc.)
|
|
329
|
+
- Storage (disk, memory)
|
|
330
|
+
- Replicas (1, 3, etc.)
|
|
331
|
+
- Limits (max message size, etc.)
|
|
332
|
+
|
|
333
|
+
**Example Infrastructure (Pulumi):**
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
import { collectStreamDefinitions } from './helpers/streams'
|
|
337
|
+
import { ensureJetStreams } from '@crossdelta/cloudevents'
|
|
338
|
+
|
|
339
|
+
// Collect from contracts
|
|
340
|
+
const streams = collectStreamDefinitions()
|
|
341
|
+
// [{ stream: 'ORDERS', subjects: ['orders.created', 'orders.updated'] }]
|
|
342
|
+
|
|
343
|
+
// Materialize with policies
|
|
344
|
+
await ensureJetStreams({
|
|
345
|
+
streams: streams.map(({ stream, subjects }) => ({
|
|
346
|
+
stream,
|
|
347
|
+
subjects,
|
|
348
|
+
config: {
|
|
349
|
+
maxAge: 14 * 24 * 60 * 60 * 1000, // 14 days
|
|
350
|
+
replicas: 3, // High availability
|
|
351
|
+
storage: 'file', // Persistent
|
|
352
|
+
},
|
|
353
|
+
})),
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Development vs. Production
|
|
358
|
+
|
|
359
|
+
| Environment | Streams | How | Retention |
|
|
360
|
+
|-------------|---------|-----|-----------|
|
|
361
|
+
| **Development** | Ephemeral | Auto-created by services | None (memory) |
|
|
362
|
+
| **Production** | Persistent | Materialized via infrastructure | Explicit (7d, 14d, etc.) |
|
|
363
|
+
|
|
364
|
+
**Key Principle:** Contracts define **what** and **where**, infrastructure defines **how** and **how long**.
|
|
365
|
+
|
|
366
|
+
### Backward Compatibility
|
|
367
|
+
|
|
368
|
+
Channel metadata is **optional** - contracts without `channel` work as before:
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
// Legacy contract (still works)
|
|
372
|
+
export const LegacyContract = createContract({
|
|
373
|
+
type: 'legacy.event',
|
|
374
|
+
schema: LegacySchema,
|
|
375
|
+
// No channel - handler still works
|
|
376
|
+
})
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
</details>
|
|
380
|
+
|
|
257
381
|
<details>
|
|
258
382
|
<summary><b>Shared Contracts</b></summary>
|
|
259
383
|
|
|
@@ -343,7 +467,7 @@ const redisStore = {
|
|
|
343
467
|
await consumeJetStreams({
|
|
344
468
|
streams: ['ORDERS'],
|
|
345
469
|
consumer: 'my-service',
|
|
346
|
-
discover: './src/events/**/*.
|
|
470
|
+
discover: './src/events/**/*.handler.ts',
|
|
347
471
|
idempotencyStore: redisStore,
|
|
348
472
|
})
|
|
349
473
|
```
|
|
@@ -359,7 +483,7 @@ Invalid messages are quarantined, not lost:
|
|
|
359
483
|
await consumeJetStreams({
|
|
360
484
|
streams: ['ORDERS'],
|
|
361
485
|
consumer: 'my-service',
|
|
362
|
-
discover: './src/events/**/*.
|
|
486
|
+
discover: './src/events/**/*.handler.ts',
|
|
363
487
|
quarantineTopic: 'events.quarantine', // For malformed messages
|
|
364
488
|
errorTopic: 'events.errors', // For handler errors
|
|
365
489
|
})
|
|
@@ -377,7 +501,7 @@ import { Hono } from 'hono'
|
|
|
377
501
|
import { cloudEvents } from '@crossdelta/cloudevents'
|
|
378
502
|
|
|
379
503
|
const app = new Hono()
|
|
380
|
-
app.use('/events', cloudEvents({ discover: 'src/events/**/*.
|
|
504
|
+
app.use('/events', cloudEvents({ discover: 'src/events/**/*.handler.ts' }))
|
|
381
505
|
```
|
|
382
506
|
|
|
383
507
|
</details>
|
|
@@ -390,9 +514,8 @@ app.use('/events', cloudEvents({ discover: 'src/events/**/*.event.ts' }))
|
|
|
390
514
|
|----------|---------|
|
|
391
515
|
| `handleEvent(options, handler)` | Create a handler |
|
|
392
516
|
| `createContract(options)` | Create shared event contract |
|
|
393
|
-
| `ensureJetStreams(options)` | Create/update JetStream streams
|
|
394
|
-
| `consumeJetStreams(options)` | Consume from multiple streams
|
|
395
|
-
| `consumeJetStreamEvents(options)` | Consume single stream (legacy) |
|
|
517
|
+
| `ensureJetStreams(options)` | Create/update JetStream streams |
|
|
518
|
+
| `consumeJetStreams(options)` | Consume from multiple streams |
|
|
396
519
|
| `consumeNatsEvents(options)` | Consume fire-and-forget |
|
|
397
520
|
| `publish(type, data)` | Publish event |
|
|
398
521
|
|
|
@@ -1,26 +1,50 @@
|
|
|
1
1
|
import type { ZodTypeAny } from 'zod';
|
|
2
|
-
import type { HandleEventOptions } from './types';
|
|
2
|
+
import type { ChannelMetadata, HandleEventOptions } from './types';
|
|
3
3
|
/**
|
|
4
|
-
* Creates a type-safe event contract
|
|
4
|
+
* Creates a type-safe event contract with optional channel metadata
|
|
5
5
|
*
|
|
6
6
|
* This helper ensures proper type inference when using contracts
|
|
7
7
|
* with handleEvent across package boundaries.
|
|
8
8
|
*
|
|
9
9
|
* @example
|
|
10
|
+
* Basic contract without channel:
|
|
10
11
|
* ```typescript
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* export const OrderCreatedContract = createContract({
|
|
13
|
+
* type: 'orders.created',
|
|
14
|
+
* schema: z.object({
|
|
15
|
+
* orderId: z.string(),
|
|
16
|
+
* total: z.number(),
|
|
17
|
+
* }),
|
|
18
|
+
* })
|
|
19
|
+
* ```
|
|
13
20
|
*
|
|
21
|
+
* @example
|
|
22
|
+
* Contract with channel metadata:
|
|
23
|
+
* ```typescript
|
|
14
24
|
* export const OrderCreatedContract = createContract({
|
|
15
25
|
* type: 'orders.created',
|
|
26
|
+
* channel: {
|
|
27
|
+
* stream: 'ORDERS',
|
|
28
|
+
* // subject defaults to 'orders.created' if omitted
|
|
29
|
+
* },
|
|
16
30
|
* schema: z.object({
|
|
17
31
|
* orderId: z.string(),
|
|
18
32
|
* total: z.number(),
|
|
19
33
|
* }),
|
|
20
34
|
* })
|
|
35
|
+
* ```
|
|
21
36
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
37
|
+
* @example
|
|
38
|
+
* Contract with explicit subject:
|
|
39
|
+
* ```typescript
|
|
40
|
+
* export const OrderCreatedContract = createContract({
|
|
41
|
+
* type: 'orders.created',
|
|
42
|
+
* channel: {
|
|
43
|
+
* stream: 'ORDERS',
|
|
44
|
+
* subject: 'orders.v1.created', // Override default
|
|
45
|
+
* },
|
|
46
|
+
* schema: OrderSchema,
|
|
47
|
+
* })
|
|
24
48
|
* ```
|
|
25
49
|
*/
|
|
26
50
|
export declare function createContract<TSchema extends ZodTypeAny>(options: {
|
|
@@ -29,6 +53,11 @@ export declare function createContract<TSchema extends ZodTypeAny>(options: {
|
|
|
29
53
|
match?: HandleEventOptions['match'];
|
|
30
54
|
safeParse?: boolean;
|
|
31
55
|
tenantId?: string | string[];
|
|
56
|
+
channel?: {
|
|
57
|
+
stream: string;
|
|
58
|
+
subject?: string;
|
|
59
|
+
};
|
|
32
60
|
}): HandleEventOptions<TSchema> & {
|
|
33
61
|
_schema: TSchema;
|
|
62
|
+
channel?: ChannelMetadata;
|
|
34
63
|
};
|
|
@@ -1,29 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Creates a type-safe event contract
|
|
2
|
+
* Creates a type-safe event contract with optional channel metadata
|
|
3
3
|
*
|
|
4
4
|
* This helper ensures proper type inference when using contracts
|
|
5
5
|
* with handleEvent across package boundaries.
|
|
6
6
|
*
|
|
7
7
|
* @example
|
|
8
|
+
* Basic contract without channel:
|
|
8
9
|
* ```typescript
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* export const OrderCreatedContract = createContract({
|
|
11
|
+
* type: 'orders.created',
|
|
12
|
+
* schema: z.object({
|
|
13
|
+
* orderId: z.string(),
|
|
14
|
+
* total: z.number(),
|
|
15
|
+
* }),
|
|
16
|
+
* })
|
|
17
|
+
* ```
|
|
11
18
|
*
|
|
19
|
+
* @example
|
|
20
|
+
* Contract with channel metadata:
|
|
21
|
+
* ```typescript
|
|
12
22
|
* export const OrderCreatedContract = createContract({
|
|
13
23
|
* type: 'orders.created',
|
|
24
|
+
* channel: {
|
|
25
|
+
* stream: 'ORDERS',
|
|
26
|
+
* // subject defaults to 'orders.created' if omitted
|
|
27
|
+
* },
|
|
14
28
|
* schema: z.object({
|
|
15
29
|
* orderId: z.string(),
|
|
16
30
|
* total: z.number(),
|
|
17
31
|
* }),
|
|
18
32
|
* })
|
|
33
|
+
* ```
|
|
19
34
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
35
|
+
* @example
|
|
36
|
+
* Contract with explicit subject:
|
|
37
|
+
* ```typescript
|
|
38
|
+
* export const OrderCreatedContract = createContract({
|
|
39
|
+
* type: 'orders.created',
|
|
40
|
+
* channel: {
|
|
41
|
+
* stream: 'ORDERS',
|
|
42
|
+
* subject: 'orders.v1.created', // Override default
|
|
43
|
+
* },
|
|
44
|
+
* schema: OrderSchema,
|
|
45
|
+
* })
|
|
22
46
|
* ```
|
|
23
47
|
*/
|
|
24
48
|
export function createContract(options) {
|
|
49
|
+
// Resolve channel metadata: default subject to type if not provided
|
|
50
|
+
const resolvedChannel = options.channel
|
|
51
|
+
? {
|
|
52
|
+
stream: options.channel.stream,
|
|
53
|
+
subject: options.channel.subject ?? options.type,
|
|
54
|
+
}
|
|
55
|
+
: undefined;
|
|
25
56
|
return {
|
|
26
57
|
...options,
|
|
27
58
|
_schema: options.schema,
|
|
59
|
+
channel: resolvedChannel,
|
|
28
60
|
};
|
|
29
61
|
}
|
|
@@ -2,17 +2,17 @@ import type { HandlerConstructor } from './types';
|
|
|
2
2
|
/**
|
|
3
3
|
* Discovers event handlers from files matching the given glob pattern.
|
|
4
4
|
*
|
|
5
|
-
* @param pattern - Glob pattern to match handler files (e.g., 'events/*.
|
|
5
|
+
* @param pattern - Glob pattern to match handler files (e.g., 'events/*.handler.ts')
|
|
6
6
|
* @param options - Configuration options
|
|
7
7
|
* @returns Promise resolving to array of discovered handler constructors
|
|
8
8
|
*
|
|
9
9
|
* @example
|
|
10
10
|
* ```typescript
|
|
11
11
|
* // Discover all handlers in events directory
|
|
12
|
-
* const handlers = await discoverHandlers('events/*.
|
|
12
|
+
* const handlers = await discoverHandlers('events/*.handler.ts')
|
|
13
13
|
*
|
|
14
14
|
* // With filtering and logging
|
|
15
|
-
* const handlers = await discoverHandlers('events/*.
|
|
15
|
+
* const handlers = await discoverHandlers('events/*.handler.ts', {
|
|
16
16
|
* filter: (name, handler) => name.includes('Customer'),
|
|
17
17
|
* log: true
|
|
18
18
|
* })
|
package/dist/domain/discovery.js
CHANGED
|
@@ -144,17 +144,17 @@ const discoverFiles = async (pattern, basePath, preferCompiled) => {
|
|
|
144
144
|
/**
|
|
145
145
|
* Discovers event handlers from files matching the given glob pattern.
|
|
146
146
|
*
|
|
147
|
-
* @param pattern - Glob pattern to match handler files (e.g., 'events/*.
|
|
147
|
+
* @param pattern - Glob pattern to match handler files (e.g., 'events/*.handler.ts')
|
|
148
148
|
* @param options - Configuration options
|
|
149
149
|
* @returns Promise resolving to array of discovered handler constructors
|
|
150
150
|
*
|
|
151
151
|
* @example
|
|
152
152
|
* ```typescript
|
|
153
153
|
* // Discover all handlers in events directory
|
|
154
|
-
* const handlers = await discoverHandlers('events/*.
|
|
154
|
+
* const handlers = await discoverHandlers('events/*.handler.ts')
|
|
155
155
|
*
|
|
156
156
|
* // With filtering and logging
|
|
157
|
-
* const handlers = await discoverHandlers('events/*.
|
|
157
|
+
* const handlers = await discoverHandlers('events/*.handler.ts', {
|
|
158
158
|
* filter: (name, handler) => name.includes('Customer'),
|
|
159
159
|
* log: true
|
|
160
160
|
* })
|
package/dist/domain/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { discoverHandlers } from './discovery';
|
|
2
1
|
export { createContract } from './contract-helper';
|
|
2
|
+
export { discoverHandlers } from './discovery';
|
|
3
3
|
export { eventSchema, handleEvent } from './handler-factory';
|
|
4
|
-
export type { EnrichedEvent, EventHandler, HandleEventOptions, HandlerConstructor, HandlerMetadata, IdempotencyStore, InferEventData, MatchFn, RoutingConfig, } from './types';
|
|
4
|
+
export type { ChannelConfig, ChannelMetadata, EnrichedEvent, EventHandler, HandleEventOptions, HandlerConstructor, HandlerMetadata, IdempotencyStore, InferEventData, MatchFn, RoutingConfig, } from './types';
|
|
5
5
|
export type { HandlerValidationError, ValidationErrorDetail } from './validation';
|
|
6
6
|
export { extractTypeFromSchema, isValidHandler } from './validation';
|
package/dist/domain/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { discoverHandlers } from './discovery';
|
|
2
1
|
export { createContract } from './contract-helper';
|
|
2
|
+
export { discoverHandlers } from './discovery';
|
|
3
3
|
export { eventSchema, handleEvent } from './handler-factory';
|
|
4
4
|
export { extractTypeFromSchema, isValidHandler } from './validation';
|
package/dist/domain/types.d.ts
CHANGED
|
@@ -43,6 +43,24 @@ export type HandlerMetadata<S extends ZodTypeAny> = {
|
|
|
43
43
|
match?: MatchFn<unknown>;
|
|
44
44
|
safeParse: boolean;
|
|
45
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* Channel metadata for NATS JetStream routing
|
|
48
|
+
*/
|
|
49
|
+
export interface ChannelMetadata {
|
|
50
|
+
/** JetStream stream name (e.g., 'ORDERS') */
|
|
51
|
+
stream: string;
|
|
52
|
+
/** NATS subject (defaults to event type if not specified) */
|
|
53
|
+
subject: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Channel configuration input (subject is optional, defaults to type)
|
|
57
|
+
*/
|
|
58
|
+
export interface ChannelConfig {
|
|
59
|
+
/** JetStream stream name (e.g., 'ORDERS') */
|
|
60
|
+
stream: string;
|
|
61
|
+
/** NATS subject (optional, defaults to event type) */
|
|
62
|
+
subject?: string;
|
|
63
|
+
}
|
|
46
64
|
/**
|
|
47
65
|
* Options for creating event handlers
|
|
48
66
|
*
|
|
@@ -56,6 +74,8 @@ export interface HandleEventOptions<S = ZodTypeAny> {
|
|
|
56
74
|
safeParse?: boolean;
|
|
57
75
|
/** Filter events by tenant ID(s). If set, only events matching these tenant(s) are processed. */
|
|
58
76
|
tenantId?: string | string[];
|
|
77
|
+
/** Channel metadata for NATS JetStream routing (optional) */
|
|
78
|
+
channel?: ChannelConfig;
|
|
59
79
|
}
|
|
60
80
|
/**
|
|
61
81
|
* Routing configuration for type → subject → stream mapping
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { EventContext } from './adapters/cloudevents';
|
|
2
2
|
export { parseEventFromContext } from './adapters/cloudevents';
|
|
3
|
-
export type { EnrichedEvent, HandleEventOptions, IdempotencyStore, InferEventData, RoutingConfig } from './domain';
|
|
3
|
+
export type { ChannelConfig, ChannelMetadata, EnrichedEvent, HandleEventOptions, IdempotencyStore, InferEventData, RoutingConfig, } from './domain';
|
|
4
4
|
export { createContract, eventSchema, extractTypeFromSchema, handleEvent } from './domain';
|
|
5
5
|
export { clearHandlerCache, cloudEvents } from './middlewares';
|
|
6
6
|
export { checkAndMarkProcessed, createInMemoryIdempotencyStore, getDefaultIdempotencyStore, resetDefaultIdempotencyStore, } from './processing/idempotency';
|
|
@@ -28,7 +28,7 @@ export interface InMemoryIdempotencyStoreOptions {
|
|
|
28
28
|
* stream: 'ORDERS',
|
|
29
29
|
* subjects: ['orders.>'],
|
|
30
30
|
* consumer: 'notifications',
|
|
31
|
-
* discover:
|
|
31
|
+
* discover: `./src/events/**\/*.handler.ts`,
|
|
32
32
|
* idempotencyStore: store,
|
|
33
33
|
* })
|
|
34
34
|
* ```
|
|
@@ -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
|
+
};
|
|
@@ -157,7 +157,7 @@ export declare function ensureJetStreamStreams(options: JetStreamStreamsOptions)
|
|
|
157
157
|
* stream: 'ORDERS',
|
|
158
158
|
* subjects: ['orders.>'],
|
|
159
159
|
* consumer: 'notifications',
|
|
160
|
-
* discover:
|
|
160
|
+
* discover: `./src/events/**\/*.handler.ts`,
|
|
161
161
|
* })
|
|
162
162
|
* ```
|
|
163
163
|
*/
|
|
@@ -200,7 +200,7 @@ export interface JetStreamStreamsConsumerOptions extends Pick<CloudEventsOptions
|
|
|
200
200
|
* await consumeJetStreamStreams({
|
|
201
201
|
* streams: ['ORDERS', 'CUSTOMERS'],
|
|
202
202
|
* consumer: 'notifications',
|
|
203
|
-
* discover:
|
|
203
|
+
* discover: `./src/events/**\/*.handler.ts`,
|
|
204
204
|
* })
|
|
205
205
|
* ```
|
|
206
206
|
*/
|
|
@@ -167,7 +167,7 @@ async function ensureConsumer(jsm, streamName, consumerName, options) {
|
|
|
167
167
|
* stream: 'ORDERS',
|
|
168
168
|
* subjects: ['orders.>'],
|
|
169
169
|
* consumer: 'notifications',
|
|
170
|
-
* discover:
|
|
170
|
+
* discover: `./src/events/**\/*.handler.ts`,
|
|
171
171
|
* })
|
|
172
172
|
* ```
|
|
173
173
|
*/
|
|
@@ -264,7 +264,7 @@ export async function consumeJetStreamEvents(options) {
|
|
|
264
264
|
* await consumeJetStreamStreams({
|
|
265
265
|
* streams: ['ORDERS', 'CUSTOMERS'],
|
|
266
266
|
* consumer: 'notifications',
|
|
267
|
-
* discover:
|
|
267
|
+
* discover: `./src/events/**\/*.handler.ts`,
|
|
268
268
|
* })
|
|
269
269
|
* ```
|
|
270
270
|
*/
|
|
@@ -44,7 +44,7 @@ export async function consumeNatsEvents(options) {
|
|
|
44
44
|
const pass = options.pass ?? process.env.NATS_PASSWORD;
|
|
45
45
|
// Cleanup existing consumer with same name (handles hot-reload)
|
|
46
46
|
await cleanupConsumer(name);
|
|
47
|
-
// 1) Discover handler classes from *.
|
|
47
|
+
// 1) Discover handler classes from *.handler.ts files
|
|
48
48
|
const handlerConstructors = await discoverHandlers(options.discover);
|
|
49
49
|
const processedHandlers = handlerConstructors
|
|
50
50
|
.map(processHandler)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossdelta/cloudevents",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "CloudEvents toolkit for TypeScript - Zod validation, handler discovery, NATS JetStream",
|
|
5
5
|
"author": "crossdelta",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
],
|
|
24
24
|
"exports": {
|
|
25
25
|
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
26
27
|
"import": "./dist/index.js",
|
|
27
28
|
"require": "./dist/index.js",
|
|
28
|
-
"types": "./dist/index.d.ts",
|
|
29
29
|
"default": "./dist/index.js"
|
|
30
30
|
},
|
|
31
31
|
"./transports/nats": {
|
|
32
|
+
"types": "./dist/src/transports/nats/index.d.ts",
|
|
32
33
|
"import": "./dist/src/transports/nats/index.js",
|
|
33
34
|
"require": "./dist/src/transports/nats/index.js",
|
|
34
|
-
"types": "./dist/src/transports/nats/index.d.ts",
|
|
35
35
|
"default": "./dist/src/transports/nats/index.js"
|
|
36
36
|
}
|
|
37
37
|
},
|