@crossdelta/cloudevents 0.1.13 → 0.3.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 +157 -195
- package/dist/src/domain/handler-factory.d.ts +23 -5
- package/dist/src/domain/handler-factory.js +101 -9
- package/dist/src/domain/index.d.ts +1 -1
- package/dist/src/domain/types.d.ts +33 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/processing/dlq-safe.d.ts +49 -1
- package/dist/src/processing/idempotency.d.ts +51 -0
- package/dist/src/processing/idempotency.js +112 -0
- package/dist/src/processing/index.d.ts +1 -0
- package/dist/src/processing/index.js +1 -0
- package/dist/src/publishing/nats.publisher.d.ts +37 -0
- package/dist/src/publishing/nats.publisher.js +63 -0
- package/dist/src/transports/nats/base-message-processor.js +9 -0
- package/dist/src/transports/nats/jetstream-consumer.d.ts +11 -0
- package/dist/src/transports/nats/jetstream-consumer.js +35 -14
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,288 +1,250 @@
|
|
|
1
1
|
# @crossdelta/cloudevents
|
|
2
2
|
|
|
3
|
-
[
|
|
4
|
-
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://www.typescriptlang.org/)
|
|
6
|
-
|
|
7
|
-
A TypeScript toolkit for [CloudEvents](https://cloudevents.io/) over [NATS](https://nats.io/).
|
|
8
|
-
|
|
9
|
-
Publish events from one service, consume them in another — with automatic handler discovery, type-safe validation, and guaranteed delivery via JetStream.
|
|
3
|
+
Type-safe event-driven microservices with [NATS](https://nats.io) and [Zod](https://zod.dev) validation, using the [CloudEvents](https://cloudevents.io) specification.
|
|
10
4
|
|
|
11
5
|
```
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
6
|
+
NATS JetStream
|
|
7
|
+
┌──────────────┐
|
|
8
|
+
┌──────────────┐ │ │ ┌──────────────┐
|
|
9
|
+
│ Service │ │ Stream: │ │ Service │
|
|
10
|
+
│ (publish) │─────▶│ ORDERS │─────▶│ (consume) │
|
|
11
|
+
└──────────────┘ │ │ └──────────────┘
|
|
12
|
+
└──────────────┘
|
|
13
|
+
│ │
|
|
14
|
+
│ publishNatsRawEvent(...) │ handleEvent(...)
|
|
15
|
+
▼ ▼
|
|
16
|
+
┌──────────────┐ ┌──────────────┐
|
|
17
|
+
│ { orderId, │ │ Zod schema │
|
|
18
|
+
│ total } │ │ validates │
|
|
19
|
+
└──────────────┘ └──────────────┘
|
|
24
20
|
```
|
|
25
21
|
|
|
26
|
-
## Why this library?
|
|
27
|
-
|
|
28
|
-
Event-driven microservices are hard: messages get lost when services restart, handlers are scattered across files, validation is inconsistent.
|
|
29
|
-
|
|
30
|
-
| Feature | Benefit |
|
|
31
|
-
|---------|---------|
|
|
32
|
-
| 🔍 **Auto-discovery** | Drop a `*.event.ts` file, it's registered automatically |
|
|
33
|
-
| 🛡️ **Type-safe handlers** | Zod schemas ensure runtime validation matches TypeScript |
|
|
34
|
-
| 🔄 **JetStream persistence** | Messages survive restarts, get retried on failure |
|
|
35
|
-
| 🏥 **DLQ-safe processing** | Invalid messages are quarantined, not lost |
|
|
36
|
-
|
|
37
|
-
## Installation
|
|
38
|
-
|
|
39
22
|
```bash
|
|
40
|
-
bun add @crossdelta/cloudevents
|
|
23
|
+
bun add @crossdelta/cloudevents zod@4
|
|
41
24
|
```
|
|
42
25
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
All NATS functions use this fallback chain for the server URL:
|
|
48
|
-
|
|
49
|
-
1. **`servers` option** (explicit) → `{ servers: 'nats://my-server:4222' }`
|
|
50
|
-
2. **`NATS_URL` env var** → `export NATS_URL=nats://my-server:4222`
|
|
51
|
-
3. **Default** → `nats://localhost:4222`
|
|
26
|
+
> **Prerequisites:** A running [NATS server](https://docs.nats.io/running-a-nats-service/introduction) with JetStream enabled.
|
|
27
|
+
>
|
|
28
|
+
> **Note:** Requires Zod v4 for full TypeScript support.
|
|
52
29
|
|
|
53
|
-
|
|
30
|
+
## Quick Start
|
|
54
31
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
A minimal example: `orders-service` publishes an event, `notifications-service` consumes it.
|
|
58
|
-
|
|
59
|
-
### 1. Publish an Event (orders-service)
|
|
32
|
+
**1. Create an event handler** (`src/handlers/order-created.event.ts`):
|
|
60
33
|
|
|
61
34
|
```typescript
|
|
62
|
-
// orders-service/src/index.ts
|
|
63
|
-
import { publishNatsEvent } from '@crossdelta/cloudevents'
|
|
64
|
-
|
|
65
|
-
// When an order is created...
|
|
66
|
-
await publishNatsEvent({
|
|
67
|
-
type: 'com.acme.orders.created',
|
|
68
|
-
source: '/orders-service',
|
|
69
|
-
data: {
|
|
70
|
-
orderId: 'ord_123',
|
|
71
|
-
customerId: 'cust_456',
|
|
72
|
-
total: 99.99,
|
|
73
|
-
},
|
|
74
|
-
})
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
### 2. Define a Handler (notifications-service)
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
// notifications-service/src/handlers/order-created.event.ts
|
|
81
|
-
import { z } from 'zod'
|
|
82
35
|
import { handleEvent } from '@crossdelta/cloudevents'
|
|
36
|
+
import { z } from 'zod'
|
|
83
37
|
|
|
84
38
|
export default handleEvent({
|
|
85
|
-
type: '
|
|
39
|
+
type: 'orders.created',
|
|
86
40
|
schema: z.object({
|
|
87
41
|
orderId: z.string(),
|
|
88
|
-
customerId: z.string(),
|
|
89
42
|
total: z.number(),
|
|
90
43
|
}),
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// Send email, push notification, etc.
|
|
94
|
-
},
|
|
44
|
+
}, async (data) => {
|
|
45
|
+
console.log(`New order: ${data.orderId}, total: ${data.total}`)
|
|
95
46
|
})
|
|
96
47
|
```
|
|
97
48
|
|
|
98
|
-
|
|
49
|
+
**2. Start consuming:**
|
|
99
50
|
|
|
100
51
|
```typescript
|
|
101
|
-
// notifications-service/src/index.ts
|
|
102
52
|
import { consumeJetStreamEvents } from '@crossdelta/cloudevents'
|
|
103
53
|
|
|
104
54
|
await consumeJetStreamEvents({
|
|
105
|
-
stream: 'ORDERS',
|
|
106
|
-
subjects: ['
|
|
107
|
-
consumer: '
|
|
108
|
-
discover: './src/handlers/**/*.event.ts',
|
|
55
|
+
stream: 'ORDERS', // Auto-created if not exists
|
|
56
|
+
subjects: ['orders.*'],
|
|
57
|
+
consumer: 'my-service',
|
|
58
|
+
discover: './src/handlers/**/*.event.ts',
|
|
109
59
|
})
|
|
60
|
+
```
|
|
110
61
|
|
|
111
|
-
|
|
62
|
+
**3. Publish from another service:**
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { publish } from '@crossdelta/cloudevents'
|
|
66
|
+
|
|
67
|
+
await publish('orders.created', { orderId: 'ord_123', total: 99.99 })
|
|
112
68
|
```
|
|
113
69
|
|
|
114
|
-
|
|
70
|
+
That's it. Handlers are auto-discovered, validated with Zod, and messages persist in JetStream.
|
|
115
71
|
|
|
116
|
-
|
|
72
|
+
---
|
|
117
73
|
|
|
118
|
-
|
|
74
|
+
## Why use this?
|
|
119
75
|
|
|
120
|
-
|
|
76
|
+
| Problem | Solution |
|
|
77
|
+
|---------|----------|
|
|
78
|
+
| Messages lost on restart | JetStream persists messages |
|
|
79
|
+
| Scattered handler registration | Auto-discovery via glob patterns |
|
|
80
|
+
| Runtime type errors | Zod validation with TypeScript inference |
|
|
81
|
+
| Poison messages crash services | DLQ quarantines invalid messages |
|
|
121
82
|
|
|
122
|
-
|
|
123
|
-
import { consumeNatsEvents } from '@crossdelta/cloudevents'
|
|
83
|
+
---
|
|
124
84
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
85
|
+
## Core Concepts
|
|
86
|
+
|
|
87
|
+
### Handlers
|
|
88
|
+
|
|
89
|
+
Drop a `*.event.ts` file anywhere — it's auto-registered:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// src/handlers/user-signup.event.ts
|
|
93
|
+
export default handleEvent({
|
|
94
|
+
type: 'users.signup',
|
|
95
|
+
schema: z.object({ email: z.string().email() }),
|
|
96
|
+
}, async (data) => {
|
|
97
|
+
await sendWelcomeEmail(data.email)
|
|
128
98
|
})
|
|
129
99
|
```
|
|
130
100
|
|
|
131
|
-
###
|
|
132
|
-
|
|
133
|
-
For critical business events that must not be lost:
|
|
101
|
+
### Publishing
|
|
134
102
|
|
|
135
103
|
```typescript
|
|
136
|
-
|
|
104
|
+
await publish('orders.created', orderData)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Consuming
|
|
137
108
|
|
|
109
|
+
```typescript
|
|
110
|
+
// JetStream (recommended) — persistent, retries, exactly-once
|
|
138
111
|
await consumeJetStreamEvents({
|
|
139
|
-
// Stream configuration
|
|
140
112
|
stream: 'ORDERS',
|
|
141
|
-
subjects: ['orders
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
113
|
+
subjects: ['orders.*'],
|
|
114
|
+
consumer: 'billing',
|
|
115
|
+
discover: './src/handlers/**/*.event.ts',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Core NATS — fire-and-forget, simpler
|
|
119
|
+
await consumeNatsEvents({
|
|
120
|
+
subjects: ['notifications.*'],
|
|
145
121
|
discover: './src/handlers/**/*.event.ts',
|
|
146
|
-
|
|
147
|
-
// Optional: Stream settings
|
|
148
|
-
streamConfig: {
|
|
149
|
-
maxAge: 7 * 24 * 60 * 60 * 1_000_000_000, // 7 days retention
|
|
150
|
-
maxBytes: 1024 * 1024 * 1024, // 1 GB max
|
|
151
|
-
numReplicas: 3, // For HA clusters
|
|
152
|
-
},
|
|
153
|
-
|
|
154
|
-
// Optional: Consumer settings
|
|
155
|
-
ackWait: 30_000, // 30s to process before retry
|
|
156
|
-
maxDeliver: 5, // Max retry attempts
|
|
157
|
-
startFrom: 'all', // 'new' | 'all' | 'last' | Date
|
|
158
122
|
})
|
|
159
123
|
```
|
|
160
124
|
|
|
161
|
-
|
|
125
|
+
---
|
|
162
126
|
|
|
163
|
-
|
|
127
|
+
## Configuration
|
|
128
|
+
|
|
129
|
+
### Environment Variables
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
NATS_URL=nats://localhost:4222
|
|
133
|
+
NATS_USER=myuser # optional
|
|
134
|
+
NATS_PASSWORD=mypass # optional
|
|
135
|
+
```
|
|
164
136
|
|
|
165
|
-
|
|
137
|
+
### Consumer Options
|
|
166
138
|
|
|
167
139
|
```typescript
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
140
|
+
await consumeJetStreamEvents({
|
|
141
|
+
// Required
|
|
142
|
+
stream: 'ORDERS',
|
|
143
|
+
subjects: ['orders.*'],
|
|
144
|
+
consumer: 'my-service',
|
|
145
|
+
discover: './src/handlers/**/*.event.ts',
|
|
146
|
+
|
|
147
|
+
// Optional
|
|
148
|
+
servers: 'nats://localhost:4222',
|
|
149
|
+
maxDeliver: 5, // Retry attempts
|
|
150
|
+
ackWait: 30_000, // Timeout per attempt (ms)
|
|
151
|
+
quarantineTopic: 'dlq', // For poison messages
|
|
177
152
|
})
|
|
178
153
|
```
|
|
179
154
|
|
|
180
|
-
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Advanced Features
|
|
158
|
+
|
|
159
|
+
<details>
|
|
160
|
+
<summary><b>Multi-Tenancy</b></summary>
|
|
181
161
|
|
|
182
|
-
|
|
162
|
+
Filter events by tenant:
|
|
183
163
|
|
|
184
164
|
```typescript
|
|
185
165
|
export default handleEvent({
|
|
186
|
-
type: '
|
|
187
|
-
schema:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
trackingNumber: z.string(),
|
|
191
|
-
}),
|
|
192
|
-
source: z.string(),
|
|
193
|
-
subject: z.string().optional(),
|
|
194
|
-
}),
|
|
195
|
-
async handle(event) {
|
|
196
|
-
// event.data, event.source, event.subject all available
|
|
197
|
-
},
|
|
198
|
-
})
|
|
166
|
+
type: 'orders.created',
|
|
167
|
+
schema: OrderSchema,
|
|
168
|
+
tenantId: 'tenant-a', // Only process tenant-a events
|
|
169
|
+
}, async (data) => { ... })
|
|
199
170
|
```
|
|
200
171
|
|
|
201
|
-
|
|
172
|
+
</details>
|
|
173
|
+
|
|
174
|
+
<details>
|
|
175
|
+
<summary><b>Custom Matching</b></summary>
|
|
202
176
|
|
|
203
|
-
|
|
177
|
+
Add custom filter logic:
|
|
204
178
|
|
|
205
179
|
```typescript
|
|
206
180
|
export default handleEvent({
|
|
207
|
-
type: '
|
|
181
|
+
type: 'orders.created',
|
|
208
182
|
schema: OrderSchema,
|
|
209
|
-
match: (event) => event.data.
|
|
210
|
-
|
|
211
|
-
// Only EU orders
|
|
212
|
-
},
|
|
213
|
-
})
|
|
183
|
+
match: (event) => event.data.total > 100, // Only high-value orders
|
|
184
|
+
}, async (data) => { ... })
|
|
214
185
|
```
|
|
215
186
|
|
|
216
|
-
|
|
187
|
+
</details>
|
|
188
|
+
|
|
189
|
+
<details>
|
|
190
|
+
<summary><b>Idempotency</b></summary>
|
|
217
191
|
|
|
218
|
-
|
|
192
|
+
Deduplication is built-in. For distributed systems, provide a Redis store:
|
|
219
193
|
|
|
220
194
|
```typescript
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
type: 'com.example.orders.created',
|
|
226
|
-
source: '/orders-service',
|
|
227
|
-
subject: 'order-123',
|
|
228
|
-
data: { orderId: '123', total: 99.99 },
|
|
229
|
-
})
|
|
195
|
+
const redisStore = {
|
|
196
|
+
has: (id) => redis.exists(`idem:${id}`),
|
|
197
|
+
add: (id, ttl) => redis.set(`idem:${id}`, '1', 'PX', ttl),
|
|
198
|
+
}
|
|
230
199
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
total: 99.99,
|
|
200
|
+
await consumeJetStreamEvents({
|
|
201
|
+
// ...
|
|
202
|
+
idempotencyStore: redisStore,
|
|
235
203
|
})
|
|
236
204
|
```
|
|
237
205
|
|
|
238
|
-
|
|
206
|
+
</details>
|
|
239
207
|
|
|
240
|
-
|
|
241
|
-
|
|
208
|
+
<details>
|
|
209
|
+
<summary><b>Dead Letter Queue</b></summary>
|
|
210
|
+
|
|
211
|
+
Invalid messages are quarantined, not lost:
|
|
242
212
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
213
|
+
```typescript
|
|
214
|
+
await consumeJetStreamEvents({
|
|
215
|
+
// ...
|
|
216
|
+
quarantineTopic: 'events.quarantine',
|
|
217
|
+
errorTopic: 'events.errors',
|
|
246
218
|
})
|
|
247
219
|
```
|
|
248
220
|
|
|
249
|
-
|
|
221
|
+
</details>
|
|
250
222
|
|
|
251
|
-
|
|
223
|
+
<details>
|
|
224
|
+
<summary><b>HTTP Ingestion (Hono)</b></summary>
|
|
252
225
|
|
|
253
226
|
```typescript
|
|
254
227
|
import { Hono } from 'hono'
|
|
255
228
|
import { cloudEvents } from '@crossdelta/cloudevents'
|
|
256
229
|
|
|
257
230
|
const app = new Hono()
|
|
258
|
-
|
|
259
|
-
app.use('/events', cloudEvents({
|
|
260
|
-
discover: 'src/handlers/**/*.event.ts',
|
|
261
|
-
dlqEnabled: true,
|
|
262
|
-
}))
|
|
231
|
+
app.use('/events', cloudEvents({ discover: 'src/handlers/**/*.event.ts' }))
|
|
263
232
|
```
|
|
264
233
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
|
272
|
-
|
|
273
|
-
| `
|
|
274
|
-
| `
|
|
275
|
-
| `
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
| Scenario | Core NATS | JetStream |
|
|
280
|
-
|----------|-----------|-----------|
|
|
281
|
-
| Service restarts | ❌ Messages lost | ✅ Messages replayed |
|
|
282
|
-
| Handler crashes | ❌ Message lost | ✅ Auto-retry with backoff |
|
|
283
|
-
| Multiple consumers | ❌ All receive same msg | ✅ Load balanced |
|
|
284
|
-
| Message history | ❌ None | ✅ Configurable retention |
|
|
285
|
-
| Exactly-once | ❌ At-most-once | ✅ With deduplication |
|
|
234
|
+
</details>
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## API
|
|
239
|
+
|
|
240
|
+
| Function | Purpose |
|
|
241
|
+
|----------|---------|
|
|
242
|
+
| `handleEvent(options, handler)` | Create a handler |
|
|
243
|
+
| `consumeJetStreamEvents(options)` | Consume with persistence |
|
|
244
|
+
| `consumeNatsEvents(options)` | Consume fire-and-forget |
|
|
245
|
+
| `publish(type, data)` | Publish event |
|
|
246
|
+
|
|
247
|
+
---
|
|
286
248
|
|
|
287
249
|
## License
|
|
288
250
|
|
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
import { type ZodTypeAny, z } from 'zod';
|
|
2
2
|
import type { EventContext } from '../adapters/cloudevents';
|
|
3
|
-
import type { HandlerConstructor } from './types';
|
|
3
|
+
import type { HandleEventOptions, HandlerConstructor } from './types';
|
|
4
4
|
/**
|
|
5
5
|
* Creates an event handler using the handleEvent pattern
|
|
6
6
|
* Compatible with the new middleware system
|
|
7
|
+
*
|
|
8
|
+
* @example Data-only schema
|
|
9
|
+
* ```typescript
|
|
10
|
+
* export default handleEvent({
|
|
11
|
+
* type: 'orderboss.orders.created',
|
|
12
|
+
* schema: z.object({ orderId: z.string(), total: z.number() }),
|
|
13
|
+
* }, async (data) => {
|
|
14
|
+
* console.log('Order created:', data.orderId)
|
|
15
|
+
* })
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @example With tenant filtering
|
|
19
|
+
* ```typescript
|
|
20
|
+
* export default handleEvent({
|
|
21
|
+
* type: 'orderboss.orders.created',
|
|
22
|
+
* schema: OrderSchema,
|
|
23
|
+
* tenantId: ['tenant-a', 'tenant-b'],
|
|
24
|
+
* }, async (data, context) => {
|
|
25
|
+
* console.log(`Order for tenant ${context?.tenantId}:`, data)
|
|
26
|
+
* })
|
|
27
|
+
* ```
|
|
7
28
|
*/
|
|
8
|
-
export declare function handleEvent<TSchema extends ZodTypeAny>(schemaOrOptions: TSchema |
|
|
9
|
-
schema: TSchema;
|
|
10
|
-
type?: string;
|
|
11
|
-
}, handler: (payload: TSchema['_output'], context?: EventContext) => Promise<void> | void, eventType?: string): HandlerConstructor;
|
|
29
|
+
export declare function handleEvent<TSchema extends ZodTypeAny>(schemaOrOptions: TSchema | HandleEventOptions<TSchema>, handler: (payload: TSchema['_output'], context?: EventContext) => Promise<void> | void, eventType?: string): HandlerConstructor;
|
|
12
30
|
/**
|
|
13
31
|
* Creates an event schema with type inference
|
|
14
32
|
* Automatically enforces the presence of a 'type' field
|
|
@@ -1,28 +1,118 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Extracts type value from Zod v4+ value getter
|
|
4
|
+
*/
|
|
5
|
+
function extractFromValueGetter(typeField) {
|
|
6
|
+
if ('value' in typeField && typeof typeField.value === 'string') {
|
|
7
|
+
return typeField.value;
|
|
8
|
+
}
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Extracts type value from Zod _def (v3 and v4)
|
|
13
|
+
*/
|
|
14
|
+
function extractFromTypeDef(typeField) {
|
|
15
|
+
if (!('_def' in typeField))
|
|
16
|
+
return undefined;
|
|
17
|
+
const typeDef = typeField._def;
|
|
18
|
+
// Try _def.values array (Zod v4)
|
|
19
|
+
if (Array.isArray(typeDef.values) && typeDef.values.length > 0) {
|
|
20
|
+
return String(typeDef.values[0]);
|
|
21
|
+
}
|
|
22
|
+
// Fallback to _def.value (Zod v3)
|
|
23
|
+
if (typeof typeDef.value === 'string') {
|
|
24
|
+
return typeDef.value;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Extracts event type from a schema's type field
|
|
30
|
+
*/
|
|
31
|
+
function extractFromTypeField(shape) {
|
|
32
|
+
const typeField = shape.type;
|
|
33
|
+
if (!typeField || typeof typeField !== 'object' || typeField === null) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const field = typeField;
|
|
37
|
+
return extractFromValueGetter(field) ?? extractFromTypeDef(field);
|
|
38
|
+
}
|
|
2
39
|
/**
|
|
3
40
|
* Extracts event type from schema
|
|
41
|
+
* Supports both old Zod (_def.value) and new Zod (_def.values / value getter)
|
|
4
42
|
*/
|
|
5
43
|
function extractEventTypeFromSchema(schema) {
|
|
6
|
-
if ('shape' in schema
|
|
7
|
-
|
|
8
|
-
if (shape.type && typeof shape.type === 'object' && shape.type !== null && '_def' in shape.type) {
|
|
9
|
-
const typeDef = shape.type._def;
|
|
10
|
-
return typeDef?.value;
|
|
11
|
-
}
|
|
44
|
+
if (!('shape' in schema) || !schema.shape || typeof schema.shape !== 'object') {
|
|
45
|
+
return undefined;
|
|
12
46
|
}
|
|
13
|
-
return
|
|
47
|
+
return extractFromTypeField(schema.shape);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Creates a tenant match function from tenant ID configuration
|
|
51
|
+
*/
|
|
52
|
+
function createTenantMatcher(tenantId) {
|
|
53
|
+
const tenantIds = Array.isArray(tenantId) ? tenantId : [tenantId];
|
|
54
|
+
return (event) => {
|
|
55
|
+
if (!event.tenantId)
|
|
56
|
+
return false;
|
|
57
|
+
return tenantIds.includes(event.tenantId);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Combines multiple match functions with AND logic
|
|
62
|
+
*/
|
|
63
|
+
function combineMatchers(matchers) {
|
|
64
|
+
return (event) => matchers.every((matcher) => matcher(event));
|
|
14
65
|
}
|
|
15
66
|
/**
|
|
16
67
|
* Creates an event handler using the handleEvent pattern
|
|
17
68
|
* Compatible with the new middleware system
|
|
69
|
+
*
|
|
70
|
+
* @example Data-only schema
|
|
71
|
+
* ```typescript
|
|
72
|
+
* export default handleEvent({
|
|
73
|
+
* type: 'orderboss.orders.created',
|
|
74
|
+
* schema: z.object({ orderId: z.string(), total: z.number() }),
|
|
75
|
+
* }, async (data) => {
|
|
76
|
+
* console.log('Order created:', data.orderId)
|
|
77
|
+
* })
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* @example With tenant filtering
|
|
81
|
+
* ```typescript
|
|
82
|
+
* export default handleEvent({
|
|
83
|
+
* type: 'orderboss.orders.created',
|
|
84
|
+
* schema: OrderSchema,
|
|
85
|
+
* tenantId: ['tenant-a', 'tenant-b'],
|
|
86
|
+
* }, async (data, context) => {
|
|
87
|
+
* console.log(`Order for tenant ${context?.tenantId}:`, data)
|
|
88
|
+
* })
|
|
89
|
+
* ```
|
|
18
90
|
*/
|
|
19
91
|
export function handleEvent(schemaOrOptions, handler, eventType) {
|
|
20
92
|
// Handle both API formats
|
|
21
93
|
let schema;
|
|
22
94
|
let finalEventType = eventType;
|
|
95
|
+
let matchFn;
|
|
96
|
+
let safeParse = false;
|
|
23
97
|
if (schemaOrOptions && typeof schemaOrOptions === 'object' && 'schema' in schemaOrOptions) {
|
|
24
|
-
|
|
25
|
-
|
|
98
|
+
const options = schemaOrOptions;
|
|
99
|
+
schema = options.schema;
|
|
100
|
+
finalEventType = options.type || eventType;
|
|
101
|
+
safeParse = options.safeParse ?? false;
|
|
102
|
+
// Build match function from options
|
|
103
|
+
const matchers = [];
|
|
104
|
+
if (options.tenantId) {
|
|
105
|
+
matchers.push(createTenantMatcher(options.tenantId));
|
|
106
|
+
}
|
|
107
|
+
if (options.match) {
|
|
108
|
+
matchers.push(options.match);
|
|
109
|
+
}
|
|
110
|
+
if (matchers.length === 1) {
|
|
111
|
+
matchFn = matchers[0];
|
|
112
|
+
}
|
|
113
|
+
else if (matchers.length > 1) {
|
|
114
|
+
matchFn = combineMatchers(matchers);
|
|
115
|
+
}
|
|
26
116
|
}
|
|
27
117
|
else {
|
|
28
118
|
schema = schemaOrOptions;
|
|
@@ -45,6 +135,8 @@ export function handleEvent(schemaOrOptions, handler, eventType) {
|
|
|
45
135
|
static __eventarcMetadata = {
|
|
46
136
|
schema,
|
|
47
137
|
declaredType: finalEventType,
|
|
138
|
+
match: matchFn,
|
|
139
|
+
safeParse,
|
|
48
140
|
};
|
|
49
141
|
async handle(payload, context) {
|
|
50
142
|
await Promise.resolve(handler(payload, context));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { discoverHandlers } from './discovery';
|
|
2
2
|
export { eventSchema, handleEvent } from './handler-factory';
|
|
3
|
-
export type { EnrichedEvent, EventHandler, HandleEventOptions, HandlerConstructor, HandlerMetadata, MatchFn, } from './types';
|
|
3
|
+
export type { EnrichedEvent, EventHandler, HandleEventOptions, HandlerConstructor, HandlerMetadata, IdempotencyStore, InferEventData, MatchFn, RoutingConfig, } from './types';
|
|
4
4
|
export type { HandlerValidationError, ValidationErrorDetail } from './validation';
|
|
5
5
|
export { extractTypeFromSchema, isValidHandler } from './validation';
|
|
@@ -27,6 +27,8 @@ export type HandlerConstructor<T = unknown> = (new (...args: unknown[]) => Event
|
|
|
27
27
|
*/
|
|
28
28
|
export interface EnrichedEvent<T> extends EventContext {
|
|
29
29
|
data: T;
|
|
30
|
+
/** Tenant identifier for multi-tenant event routing */
|
|
31
|
+
tenantId?: string;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
34
|
* Match function type for custom event matching
|
|
@@ -49,4 +51,35 @@ export interface HandleEventOptions<S extends ZodTypeAny> {
|
|
|
49
51
|
type?: string;
|
|
50
52
|
match?: MatchFn<unknown>;
|
|
51
53
|
safeParse?: boolean;
|
|
54
|
+
/** Filter events by tenant ID(s). If set, only events matching these tenant(s) are processed. */
|
|
55
|
+
tenantId?: string | string[];
|
|
52
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Routing configuration for type → subject → stream mapping
|
|
59
|
+
*/
|
|
60
|
+
export interface RoutingConfig {
|
|
61
|
+
/** Map event type prefix to NATS subject prefix, e.g., { 'orderboss.orders': 'orders' } */
|
|
62
|
+
typeToSubjectMap?: Record<string, string>;
|
|
63
|
+
/** Map event type prefix to stream name, e.g., { 'orderboss.orders': 'ORDERS' } */
|
|
64
|
+
typeToStreamMap?: Record<string, string>;
|
|
65
|
+
/** Default subject prefix when no mapping found */
|
|
66
|
+
defaultSubjectPrefix?: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Idempotency store interface for deduplication
|
|
70
|
+
*/
|
|
71
|
+
export interface IdempotencyStore {
|
|
72
|
+
/** Check if a message has already been processed */
|
|
73
|
+
has(messageId: string): Promise<boolean> | boolean;
|
|
74
|
+
/** Mark a message as processed */
|
|
75
|
+
add(messageId: string, ttlMs?: number): Promise<void> | void;
|
|
76
|
+
/** Clear the store (useful for testing) */
|
|
77
|
+
clear?(): Promise<void> | void;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Type helper to extract data type from a Zod schema
|
|
81
|
+
* Handles both data-only schemas and full CloudEvent schemas with a 'data' field
|
|
82
|
+
*/
|
|
83
|
+
export type InferEventData<S extends ZodTypeAny> = S['_output'] extends {
|
|
84
|
+
data: infer D;
|
|
85
|
+
} ? D : S['_output'];
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type { EventContext } from './adapters/cloudevents';
|
|
2
2
|
export { parseEventFromContext } from './adapters/cloudevents';
|
|
3
|
+
export type { EnrichedEvent, IdempotencyStore, InferEventData, RoutingConfig } from './domain';
|
|
3
4
|
export { eventSchema, extractTypeFromSchema, handleEvent } from './domain';
|
|
4
5
|
export { clearHandlerCache, cloudEvents } from './middlewares';
|
|
6
|
+
export { checkAndMarkProcessed, createInMemoryIdempotencyStore, getDefaultIdempotencyStore, resetDefaultIdempotencyStore, } from './processing/idempotency';
|
|
5
7
|
export * from './publishing';
|
package/dist/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { parseEventFromContext } from './adapters/cloudevents';
|
|
2
2
|
export { eventSchema, extractTypeFromSchema, handleEvent } from './domain';
|
|
3
3
|
export { clearHandlerCache, cloudEvents } from './middlewares';
|
|
4
|
+
export { checkAndMarkProcessed, createInMemoryIdempotencyStore, getDefaultIdempotencyStore, resetDefaultIdempotencyStore, } from './processing/idempotency';
|
|
4
5
|
export * from './publishing';
|
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
* DLQ-Safe mode utilities
|
|
3
3
|
* Handles quarantine and error publishing for CloudEvents processing
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* Reason codes for quarantined messages
|
|
7
|
+
*/
|
|
8
|
+
export type QuarantineReason = 'parse_error' | 'no_handler' | 'validation_error' | 'unhandled_error' | 'processing_error';
|
|
9
|
+
/**
|
|
10
|
+
* Structure of messages published to the quarantine topic
|
|
11
|
+
*/
|
|
12
|
+
export interface QuarantineMessage {
|
|
13
|
+
/** Original CloudEvent ID */
|
|
14
|
+
originalMessageId: string;
|
|
15
|
+
/** Original CloudEvent type */
|
|
16
|
+
originalEventType: string;
|
|
17
|
+
/** Original event data payload */
|
|
18
|
+
originalEventData: unknown;
|
|
19
|
+
/** Original event context/metadata */
|
|
20
|
+
originalEventContext: unknown;
|
|
21
|
+
/** Full original CloudEvent if available */
|
|
22
|
+
originalCloudEvent: unknown;
|
|
23
|
+
/** ISO timestamp when message was quarantined */
|
|
24
|
+
quarantinedAt: string;
|
|
25
|
+
/** Reason for quarantine */
|
|
26
|
+
quarantineReason: QuarantineReason;
|
|
27
|
+
/** Error message if applicable */
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Structure of messages published to the error topic (recoverable errors)
|
|
32
|
+
*/
|
|
33
|
+
export interface ErrorMessage {
|
|
34
|
+
/** Original CloudEvent ID */
|
|
35
|
+
originalMessageId: string;
|
|
36
|
+
/** Original CloudEvent type */
|
|
37
|
+
originalEventType: string;
|
|
38
|
+
/** Original event data payload */
|
|
39
|
+
originalEventData: unknown;
|
|
40
|
+
/** Original event context/metadata */
|
|
41
|
+
originalEventContext: unknown;
|
|
42
|
+
/** Full original CloudEvent if available */
|
|
43
|
+
originalCloudEvent: unknown;
|
|
44
|
+
/** ISO timestamp when error occurred */
|
|
45
|
+
errorTimestamp: string;
|
|
46
|
+
/** Error details */
|
|
47
|
+
error: {
|
|
48
|
+
message: string;
|
|
49
|
+
stack?: string;
|
|
50
|
+
type: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
5
53
|
export interface ProcessingContext {
|
|
6
54
|
messageId: string;
|
|
7
55
|
eventType: string;
|
|
@@ -23,7 +71,7 @@ export declare const isDlqSafeMode: (options: DlqOptions) => boolean;
|
|
|
23
71
|
/**
|
|
24
72
|
* Publishes a message to the quarantine topic for "poison messages" that can't be processed
|
|
25
73
|
*/
|
|
26
|
-
export declare const quarantineMessage: (processingContext: ProcessingContext, reason:
|
|
74
|
+
export declare const quarantineMessage: (processingContext: ProcessingContext, reason: QuarantineReason, options: DlqOptions, error?: unknown) => Promise<void>;
|
|
27
75
|
/**
|
|
28
76
|
* Publishes recoverable processing errors to the error topic
|
|
29
77
|
*/
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency utilities for CloudEvents processing
|
|
3
|
+
*
|
|
4
|
+
* Provides deduplication support to ensure handlers process each message exactly once.
|
|
5
|
+
* Uses CloudEvent ID as the deduplication key.
|
|
6
|
+
*/
|
|
7
|
+
import type { IdempotencyStore } from '../domain/types';
|
|
8
|
+
/**
|
|
9
|
+
* Options for the in-memory idempotency store
|
|
10
|
+
*/
|
|
11
|
+
export interface InMemoryIdempotencyStoreOptions {
|
|
12
|
+
/** Maximum number of message IDs to store (LRU eviction). @default 10000 */
|
|
13
|
+
maxSize?: number;
|
|
14
|
+
/** Default TTL for entries in milliseconds. @default 86400000 (24 hours) */
|
|
15
|
+
defaultTtlMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Creates an in-memory idempotency store with LRU eviction and TTL support.
|
|
19
|
+
*
|
|
20
|
+
* Suitable for single-instance deployments or development. For production
|
|
21
|
+
* multi-instance deployments, use a Redis-based store.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* const store = createInMemoryIdempotencyStore({ maxSize: 5000, defaultTtlMs: 3600000 })
|
|
26
|
+
*
|
|
27
|
+
* await consumeJetStreamEvents({
|
|
28
|
+
* stream: 'ORDERS',
|
|
29
|
+
* subjects: ['orders.>'],
|
|
30
|
+
* consumer: 'notifications',
|
|
31
|
+
* discover: './src/handlers/**\/*.event.ts',
|
|
32
|
+
* idempotencyStore: store,
|
|
33
|
+
* })
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function createInMemoryIdempotencyStore(options?: InMemoryIdempotencyStoreOptions): IdempotencyStore;
|
|
37
|
+
/**
|
|
38
|
+
* Gets or creates the default idempotency store
|
|
39
|
+
*/
|
|
40
|
+
export declare function getDefaultIdempotencyStore(): IdempotencyStore;
|
|
41
|
+
/**
|
|
42
|
+
* Resets the default idempotency store. Useful for testing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resetDefaultIdempotencyStore(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Checks if a message should be processed (not a duplicate)
|
|
47
|
+
* and marks it as processed if so.
|
|
48
|
+
*
|
|
49
|
+
* @returns true if the message should be processed, false if it's a duplicate
|
|
50
|
+
*/
|
|
51
|
+
export declare function checkAndMarkProcessed(store: IdempotencyStore, messageId: string, ttlMs?: number): Promise<boolean>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency utilities for CloudEvents processing
|
|
3
|
+
*
|
|
4
|
+
* Provides deduplication support to ensure handlers process each message exactly once.
|
|
5
|
+
* Uses CloudEvent ID as the deduplication key.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Creates an in-memory idempotency store with LRU eviction and TTL support.
|
|
9
|
+
*
|
|
10
|
+
* Suitable for single-instance deployments or development. For production
|
|
11
|
+
* multi-instance deployments, use a Redis-based store.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const store = createInMemoryIdempotencyStore({ maxSize: 5000, defaultTtlMs: 3600000 })
|
|
16
|
+
*
|
|
17
|
+
* await consumeJetStreamEvents({
|
|
18
|
+
* stream: 'ORDERS',
|
|
19
|
+
* subjects: ['orders.>'],
|
|
20
|
+
* consumer: 'notifications',
|
|
21
|
+
* discover: './src/handlers/**\/*.event.ts',
|
|
22
|
+
* idempotencyStore: store,
|
|
23
|
+
* })
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function createInMemoryIdempotencyStore(options = {}) {
|
|
27
|
+
const { maxSize = 10_000, defaultTtlMs = 24 * 60 * 60 * 1000 } = options;
|
|
28
|
+
// Use Map for insertion-order iteration (LRU approximation)
|
|
29
|
+
const cache = new Map();
|
|
30
|
+
const evictExpired = () => {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const [key, entry] of cache) {
|
|
33
|
+
if (entry.expiresAt <= now) {
|
|
34
|
+
cache.delete(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const evictOldest = (count) => {
|
|
39
|
+
const keysToDelete = [...cache.keys()].slice(0, count);
|
|
40
|
+
for (const key of keysToDelete) {
|
|
41
|
+
cache.delete(key);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
has(messageId) {
|
|
46
|
+
const entry = cache.get(messageId);
|
|
47
|
+
if (!entry)
|
|
48
|
+
return false;
|
|
49
|
+
// Check if expired
|
|
50
|
+
if (entry.expiresAt <= Date.now()) {
|
|
51
|
+
cache.delete(messageId);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
// Move to end for LRU (re-insert)
|
|
55
|
+
cache.delete(messageId);
|
|
56
|
+
cache.set(messageId, entry);
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
add(messageId, ttlMs) {
|
|
60
|
+
// Evict expired entries periodically
|
|
61
|
+
if (cache.size >= maxSize) {
|
|
62
|
+
evictExpired();
|
|
63
|
+
}
|
|
64
|
+
// If still at capacity, evict oldest entries
|
|
65
|
+
if (cache.size >= maxSize) {
|
|
66
|
+
const evictCount = Math.max(1, Math.floor(maxSize * 0.1)); // Evict 10%
|
|
67
|
+
evictOldest(evictCount);
|
|
68
|
+
}
|
|
69
|
+
cache.set(messageId, {
|
|
70
|
+
expiresAt: Date.now() + (ttlMs ?? defaultTtlMs),
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
clear() {
|
|
74
|
+
cache.clear();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Default idempotency store instance.
|
|
80
|
+
* Used when no custom store is provided to consumers.
|
|
81
|
+
*/
|
|
82
|
+
let defaultStore = null;
|
|
83
|
+
/**
|
|
84
|
+
* Gets or creates the default idempotency store
|
|
85
|
+
*/
|
|
86
|
+
export function getDefaultIdempotencyStore() {
|
|
87
|
+
if (!defaultStore) {
|
|
88
|
+
defaultStore = createInMemoryIdempotencyStore();
|
|
89
|
+
}
|
|
90
|
+
return defaultStore;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resets the default idempotency store. Useful for testing.
|
|
94
|
+
*/
|
|
95
|
+
export function resetDefaultIdempotencyStore() {
|
|
96
|
+
defaultStore?.clear?.();
|
|
97
|
+
defaultStore = null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Checks if a message should be processed (not a duplicate)
|
|
101
|
+
* and marks it as processed if so.
|
|
102
|
+
*
|
|
103
|
+
* @returns true if the message should be processed, false if it's a duplicate
|
|
104
|
+
*/
|
|
105
|
+
export async function checkAndMarkProcessed(store, messageId, ttlMs) {
|
|
106
|
+
const isDuplicate = await store.has(messageId);
|
|
107
|
+
if (isDuplicate) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
await store.add(messageId, ttlMs);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ZodTypeAny } from 'zod';
|
|
2
|
+
import { type RoutingConfig } from '../domain';
|
|
2
3
|
export interface PublishNatsEventOptions {
|
|
3
4
|
/**
|
|
4
5
|
* NATS URL(s), e.g., "nats://localhost:4222".
|
|
@@ -13,9 +14,45 @@ export interface PublishNatsEventOptions {
|
|
|
13
14
|
* Optional CloudEvent subject (e.g., an order ID). Not to be confused with the NATS subject.
|
|
14
15
|
*/
|
|
15
16
|
subject?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Tenant identifier for multi-tenant event routing.
|
|
19
|
+
* Will be added as a CloudEvent extension attribute.
|
|
20
|
+
*/
|
|
21
|
+
tenantId?: string;
|
|
16
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Derives a NATS subject from a CloudEvent type using routing configuration.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const config: RoutingConfig = {
|
|
29
|
+
* typeToSubjectMap: { 'orderboss.orders': 'orders' },
|
|
30
|
+
* defaultSubjectPrefix: 'events',
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* deriveSubjectFromType('orderboss.orders.created', config)
|
|
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;
|
|
17
45
|
export declare function publishNatsEvent<T extends ZodTypeAny>(subjectName: string, schema: T, eventData: unknown, options?: PublishNatsEventOptions): Promise<string>;
|
|
18
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>;
|
|
19
56
|
/**
|
|
20
57
|
* @internal Resets the cached NATS connection. Intended for testing only.
|
|
21
58
|
*/
|
|
@@ -2,6 +2,57 @@ 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
|
+
* Derives a NATS subject from a CloudEvent type using routing configuration.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const config: RoutingConfig = {
|
|
11
|
+
* typeToSubjectMap: { 'orderboss.orders': 'orders' },
|
|
12
|
+
* defaultSubjectPrefix: 'events',
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* deriveSubjectFromType('orderboss.orders.created', config)
|
|
16
|
+
* // Returns: 'orders.created'
|
|
17
|
+
*
|
|
18
|
+
* deriveSubjectFromType('unknown.event.type', config)
|
|
19
|
+
* // Returns: 'events.unknown.event.type'
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function deriveSubjectFromType(eventType, config) {
|
|
23
|
+
if (!config?.typeToSubjectMap) {
|
|
24
|
+
// No mapping configured, use type as-is (replace dots with dots is a no-op)
|
|
25
|
+
return config?.defaultSubjectPrefix ? `${config.defaultSubjectPrefix}.${eventType}` : eventType;
|
|
26
|
+
}
|
|
27
|
+
// Find the longest matching prefix
|
|
28
|
+
const sortedPrefixes = Object.keys(config.typeToSubjectMap).sort((a, b) => b.length - a.length);
|
|
29
|
+
for (const prefix of sortedPrefixes) {
|
|
30
|
+
if (eventType.startsWith(prefix)) {
|
|
31
|
+
const suffix = eventType.slice(prefix.length);
|
|
32
|
+
const mappedPrefix = config.typeToSubjectMap[prefix];
|
|
33
|
+
// Remove leading dot from suffix if present
|
|
34
|
+
const cleanSuffix = suffix.startsWith('.') ? suffix.slice(1) : suffix;
|
|
35
|
+
return cleanSuffix ? `${mappedPrefix}.${cleanSuffix}` : mappedPrefix;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// No match found, use default prefix or type as-is
|
|
39
|
+
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) {
|
|
45
|
+
if (!config?.typeToStreamMap)
|
|
46
|
+
return undefined;
|
|
47
|
+
// Find the longest matching prefix
|
|
48
|
+
const sortedPrefixes = Object.keys(config.typeToStreamMap).sort((a, b) => b.length - a.length);
|
|
49
|
+
for (const prefix of sortedPrefixes) {
|
|
50
|
+
if (eventType.startsWith(prefix)) {
|
|
51
|
+
return config.typeToStreamMap[prefix];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
5
56
|
let natsConnectionPromise = null;
|
|
6
57
|
async function getNatsConnection(servers) {
|
|
7
58
|
if (!natsConnectionPromise) {
|
|
@@ -51,6 +102,7 @@ export async function publishNatsRawEvent(subjectName, eventType, eventData, opt
|
|
|
51
102
|
datacontenttype: 'application/json',
|
|
52
103
|
data: eventData,
|
|
53
104
|
...(options?.subject && { subject: options.subject }),
|
|
105
|
+
...(options?.tenantId && { tenantid: options.tenantId }), // CloudEvents extension (lowercase)
|
|
54
106
|
};
|
|
55
107
|
const data = JSON.stringify(cloudEvent);
|
|
56
108
|
const nc = await getNatsConnection(options?.servers);
|
|
@@ -58,6 +110,17 @@ export async function publishNatsRawEvent(subjectName, eventType, eventData, opt
|
|
|
58
110
|
logger.debug(`Published CloudEvent ${eventType} to NATS subject ${subjectName} (id=${cloudEvent.id})`);
|
|
59
111
|
return cloudEvent.id;
|
|
60
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
|
+
}
|
|
61
124
|
/**
|
|
62
125
|
* @internal Resets the cached NATS connection. Intended for testing only.
|
|
63
126
|
*/
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createProcessingContext, publishRecoverableError, quarantineMessage, } from '../../processing/dlq-safe';
|
|
6
6
|
import { throwValidationError, validateEventData } from '../../processing/validation';
|
|
7
|
+
/**
|
|
8
|
+
* Extracts tenantId from CloudEvent extensions
|
|
9
|
+
* Supports both 'tenantid' (CloudEvents spec lowercase) and 'tenantId' variants
|
|
10
|
+
*/
|
|
11
|
+
function extractTenantId(ce) {
|
|
12
|
+
const extensions = ce;
|
|
13
|
+
return (extensions.tenantid ?? extensions.tenantId ?? extensions.tenant_id);
|
|
14
|
+
}
|
|
7
15
|
/**
|
|
8
16
|
* Creates shared message processing utilities
|
|
9
17
|
*/
|
|
@@ -16,6 +24,7 @@ export function createBaseMessageProcessor(deps) {
|
|
|
16
24
|
time: ce.time ?? new Date().toISOString(),
|
|
17
25
|
messageId: ce.id,
|
|
18
26
|
data: ce.data,
|
|
27
|
+
tenantId: extractTenantId(ce),
|
|
19
28
|
});
|
|
20
29
|
const createContext = (event, ce) => createProcessingContext(event.eventType, event.data, event, ce);
|
|
21
30
|
const parseCloudEvent = (data) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ConsumerMessages } from 'nats';
|
|
2
|
+
import { type IdempotencyStore } from '../../domain';
|
|
2
3
|
import type { CloudEventsOptions } from '../../middlewares/cloudevents-middleware';
|
|
3
4
|
/**
|
|
4
5
|
* Stream configuration options
|
|
@@ -52,6 +53,16 @@ export interface JetStreamConsumerOptions extends Pick<CloudEventsOptions, 'quar
|
|
|
52
53
|
maxDeliver?: number;
|
|
53
54
|
/** Stream configuration (only used when auto-creating) */
|
|
54
55
|
streamConfig?: StreamConfig;
|
|
56
|
+
/**
|
|
57
|
+
* Idempotency store for deduplication. Defaults to in-memory store.
|
|
58
|
+
* Pass `false` to disable idempotency checks entirely.
|
|
59
|
+
*/
|
|
60
|
+
idempotencyStore?: IdempotencyStore | false;
|
|
61
|
+
/**
|
|
62
|
+
* TTL for idempotency records in milliseconds.
|
|
63
|
+
* @default 86400000 (24 hours)
|
|
64
|
+
*/
|
|
65
|
+
idempotencyTtl?: number;
|
|
55
66
|
}
|
|
56
67
|
/**
|
|
57
68
|
* Consume CloudEvents from NATS JetStream with persistence and guaranteed delivery.
|
|
@@ -2,6 +2,7 @@ import { AckPolicy, connect, DeliverPolicy, ReplayPolicy, RetentionPolicy, Stora
|
|
|
2
2
|
import { discoverHandlers } from '../../domain';
|
|
3
3
|
import { logger } from '../../infrastructure/logging';
|
|
4
4
|
import { processHandler } from '../../processing/handler-cache';
|
|
5
|
+
import { checkAndMarkProcessed, getDefaultIdempotencyStore } from '../../processing/idempotency';
|
|
5
6
|
import { createJetStreamMessageProcessor } from './jetstream-message-processor';
|
|
6
7
|
const sc = StringCodec();
|
|
7
8
|
// Use globalThis to persist across hot-reloads
|
|
@@ -155,6 +156,9 @@ export async function consumeJetStreamEvents(options) {
|
|
|
155
156
|
const abortController = new AbortController();
|
|
156
157
|
getJetStreamRegistry().set(name, { messages, connection: nc, abortController });
|
|
157
158
|
const dlqEnabled = Boolean(options.quarantineTopic || options.errorTopic);
|
|
159
|
+
// Setup idempotency store
|
|
160
|
+
const idempotencyStore = options.idempotencyStore === false ? null : (options.idempotencyStore ?? getDefaultIdempotencyStore());
|
|
161
|
+
const idempotencyTtl = options.idempotencyTtl;
|
|
158
162
|
const { handleMessage, handleUnhandledProcessingError } = createJetStreamMessageProcessor({
|
|
159
163
|
name,
|
|
160
164
|
dlqEnabled,
|
|
@@ -163,25 +167,42 @@ export async function consumeJetStreamEvents(options) {
|
|
|
163
167
|
decode: (data) => sc.decode(data),
|
|
164
168
|
logger,
|
|
165
169
|
});
|
|
170
|
+
// Check idempotency and skip duplicates
|
|
171
|
+
const checkIdempotency = async (msg) => {
|
|
172
|
+
if (!idempotencyStore)
|
|
173
|
+
return true;
|
|
174
|
+
const messageId = `${msg.info.stream}:${msg.seq}`;
|
|
175
|
+
const shouldProcess = await checkAndMarkProcessed(idempotencyStore, messageId, idempotencyTtl);
|
|
176
|
+
if (!shouldProcess) {
|
|
177
|
+
logger.debug(`[${name}] skipping duplicate message: ${messageId}`);
|
|
178
|
+
msg.ack();
|
|
179
|
+
}
|
|
180
|
+
return shouldProcess;
|
|
181
|
+
};
|
|
182
|
+
// Process a single message
|
|
183
|
+
const processSingleMessage = async (msg) => {
|
|
184
|
+
const shouldProcess = await checkIdempotency(msg);
|
|
185
|
+
if (!shouldProcess)
|
|
186
|
+
return;
|
|
187
|
+
try {
|
|
188
|
+
const success = await handleMessage(msg);
|
|
189
|
+
if (success) {
|
|
190
|
+
msg.ack();
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
msg.nak();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
await handleUnhandledProcessingError(msg, error);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
166
200
|
// Process messages
|
|
167
201
|
const processMessages = async () => {
|
|
168
202
|
for await (const msg of messages) {
|
|
169
203
|
if (abortController.signal.aborted)
|
|
170
204
|
break;
|
|
171
|
-
|
|
172
|
-
const success = await handleMessage(msg);
|
|
173
|
-
if (success) {
|
|
174
|
-
msg.ack();
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
// Handler returned false, negative ack for retry
|
|
178
|
-
msg.nak();
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
catch (error) {
|
|
182
|
-
await handleUnhandledProcessingError(msg, error);
|
|
183
|
-
// Don't ack - message will be redelivered after ack_wait
|
|
184
|
-
}
|
|
205
|
+
await processSingleMessage(msg);
|
|
185
206
|
}
|
|
186
207
|
};
|
|
187
208
|
processMessages().catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossdelta/cloudevents",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "CloudEvents toolkit for TypeScript -
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "CloudEvents toolkit for TypeScript - Zod validation, handler discovery, NATS JetStream",
|
|
5
5
|
"author": "crossdelta",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"cloudevents",
|
|
9
9
|
"nats",
|
|
10
|
+
"zod",
|
|
10
11
|
"pubsub",
|
|
11
12
|
"event-driven",
|
|
12
13
|
"typescript",
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
51
52
|
"hono": "^4.6.0",
|
|
52
|
-
"zod": "^
|
|
53
|
+
"zod": "^4.0.0"
|
|
53
54
|
},
|
|
54
55
|
"trustedDependencies": [],
|
|
55
56
|
"devDependencies": {
|