@crossdelta/cloudevents 0.1.13 → 0.2.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 +155 -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 +3 -2
package/README.md
CHANGED
|
@@ -1,288 +1,248 @@
|
|
|
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
|
|
41
24
|
```
|
|
42
25
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
### NATS Connection
|
|
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.
|
|
52
27
|
|
|
53
|
-
|
|
28
|
+
## Quick Start
|
|
54
29
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
A minimal example: `orders-service` publishes an event, `notifications-service` consumes it.
|
|
58
|
-
|
|
59
|
-
### 1. Publish an Event (orders-service)
|
|
30
|
+
**1. Create an event handler** (`src/handlers/order-created.event.ts`):
|
|
60
31
|
|
|
61
32
|
```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
33
|
import { handleEvent } from '@crossdelta/cloudevents'
|
|
34
|
+
import { z } from 'zod'
|
|
83
35
|
|
|
84
36
|
export default handleEvent({
|
|
85
|
-
type: '
|
|
37
|
+
type: 'orders.created',
|
|
86
38
|
schema: z.object({
|
|
87
39
|
orderId: z.string(),
|
|
88
|
-
customerId: z.string(),
|
|
89
40
|
total: z.number(),
|
|
90
41
|
}),
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// Send email, push notification, etc.
|
|
94
|
-
},
|
|
42
|
+
}, async (data) => {
|
|
43
|
+
console.log(`New order: ${data.orderId}, total: ${data.total}`)
|
|
95
44
|
})
|
|
96
45
|
```
|
|
97
46
|
|
|
98
|
-
|
|
47
|
+
**2. Start consuming:**
|
|
99
48
|
|
|
100
49
|
```typescript
|
|
101
|
-
// notifications-service/src/index.ts
|
|
102
50
|
import { consumeJetStreamEvents } from '@crossdelta/cloudevents'
|
|
103
51
|
|
|
104
52
|
await consumeJetStreamEvents({
|
|
105
|
-
stream: 'ORDERS',
|
|
106
|
-
subjects: ['
|
|
107
|
-
consumer: '
|
|
108
|
-
discover: './src/handlers/**/*.event.ts',
|
|
53
|
+
stream: 'ORDERS', // Auto-created if not exists
|
|
54
|
+
subjects: ['orders.*'],
|
|
55
|
+
consumer: 'my-service',
|
|
56
|
+
discover: './src/handlers/**/*.event.ts',
|
|
109
57
|
})
|
|
58
|
+
```
|
|
110
59
|
|
|
111
|
-
|
|
60
|
+
**3. Publish from another service:**
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { publish } from '@crossdelta/cloudevents'
|
|
64
|
+
|
|
65
|
+
await publish('orders.created', { orderId: 'ord_123', total: 99.99 })
|
|
112
66
|
```
|
|
113
67
|
|
|
114
|
-
|
|
68
|
+
That's it. Handlers are auto-discovered, validated with Zod, and messages persist in JetStream.
|
|
115
69
|
|
|
116
|
-
|
|
70
|
+
---
|
|
117
71
|
|
|
118
|
-
|
|
72
|
+
## Why use this?
|
|
119
73
|
|
|
120
|
-
|
|
74
|
+
| Problem | Solution |
|
|
75
|
+
|---------|----------|
|
|
76
|
+
| Messages lost on restart | JetStream persists messages |
|
|
77
|
+
| Scattered handler registration | Auto-discovery via glob patterns |
|
|
78
|
+
| Runtime type errors | Zod validation with TypeScript inference |
|
|
79
|
+
| Poison messages crash services | DLQ quarantines invalid messages |
|
|
121
80
|
|
|
122
|
-
|
|
123
|
-
import { consumeNatsEvents } from '@crossdelta/cloudevents'
|
|
81
|
+
---
|
|
124
82
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
83
|
+
## Core Concepts
|
|
84
|
+
|
|
85
|
+
### Handlers
|
|
86
|
+
|
|
87
|
+
Drop a `*.event.ts` file anywhere — it's auto-registered:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// src/handlers/user-signup.event.ts
|
|
91
|
+
export default handleEvent({
|
|
92
|
+
type: 'users.signup',
|
|
93
|
+
schema: z.object({ email: z.string().email() }),
|
|
94
|
+
}, async (data) => {
|
|
95
|
+
await sendWelcomeEmail(data.email)
|
|
128
96
|
})
|
|
129
97
|
```
|
|
130
98
|
|
|
131
|
-
###
|
|
132
|
-
|
|
133
|
-
For critical business events that must not be lost:
|
|
99
|
+
### Publishing
|
|
134
100
|
|
|
135
101
|
```typescript
|
|
136
|
-
|
|
102
|
+
await publish('orders.created', orderData)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Consuming
|
|
137
106
|
|
|
107
|
+
```typescript
|
|
108
|
+
// JetStream (recommended) — persistent, retries, exactly-once
|
|
138
109
|
await consumeJetStreamEvents({
|
|
139
|
-
// Stream configuration
|
|
140
110
|
stream: 'ORDERS',
|
|
141
|
-
subjects: ['orders
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
111
|
+
subjects: ['orders.*'],
|
|
112
|
+
consumer: 'billing',
|
|
113
|
+
discover: './src/handlers/**/*.event.ts',
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Core NATS — fire-and-forget, simpler
|
|
117
|
+
await consumeNatsEvents({
|
|
118
|
+
subjects: ['notifications.*'],
|
|
145
119
|
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
120
|
})
|
|
159
121
|
```
|
|
160
122
|
|
|
161
|
-
|
|
123
|
+
---
|
|
162
124
|
|
|
163
|
-
|
|
125
|
+
## Configuration
|
|
126
|
+
|
|
127
|
+
### Environment Variables
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
NATS_URL=nats://localhost:4222
|
|
131
|
+
NATS_USER=myuser # optional
|
|
132
|
+
NATS_PASSWORD=mypass # optional
|
|
133
|
+
```
|
|
164
134
|
|
|
165
|
-
|
|
135
|
+
### Consumer Options
|
|
166
136
|
|
|
167
137
|
```typescript
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
138
|
+
await consumeJetStreamEvents({
|
|
139
|
+
// Required
|
|
140
|
+
stream: 'ORDERS',
|
|
141
|
+
subjects: ['orders.*'],
|
|
142
|
+
consumer: 'my-service',
|
|
143
|
+
discover: './src/handlers/**/*.event.ts',
|
|
144
|
+
|
|
145
|
+
// Optional
|
|
146
|
+
servers: 'nats://localhost:4222',
|
|
147
|
+
maxDeliver: 5, // Retry attempts
|
|
148
|
+
ackWait: 30_000, // Timeout per attempt (ms)
|
|
149
|
+
quarantineTopic: 'dlq', // For poison messages
|
|
177
150
|
})
|
|
178
151
|
```
|
|
179
152
|
|
|
180
|
-
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Advanced Features
|
|
156
|
+
|
|
157
|
+
<details>
|
|
158
|
+
<summary><b>Multi-Tenancy</b></summary>
|
|
181
159
|
|
|
182
|
-
|
|
160
|
+
Filter events by tenant:
|
|
183
161
|
|
|
184
162
|
```typescript
|
|
185
163
|
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
|
-
})
|
|
164
|
+
type: 'orders.created',
|
|
165
|
+
schema: OrderSchema,
|
|
166
|
+
tenantId: 'tenant-a', // Only process tenant-a events
|
|
167
|
+
}, async (data) => { ... })
|
|
199
168
|
```
|
|
200
169
|
|
|
201
|
-
|
|
170
|
+
</details>
|
|
171
|
+
|
|
172
|
+
<details>
|
|
173
|
+
<summary><b>Custom Matching</b></summary>
|
|
202
174
|
|
|
203
|
-
|
|
175
|
+
Add custom filter logic:
|
|
204
176
|
|
|
205
177
|
```typescript
|
|
206
178
|
export default handleEvent({
|
|
207
|
-
type: '
|
|
179
|
+
type: 'orders.created',
|
|
208
180
|
schema: OrderSchema,
|
|
209
|
-
match: (event) => event.data.
|
|
210
|
-
|
|
211
|
-
// Only EU orders
|
|
212
|
-
},
|
|
213
|
-
})
|
|
181
|
+
match: (event) => event.data.total > 100, // Only high-value orders
|
|
182
|
+
}, async (data) => { ... })
|
|
214
183
|
```
|
|
215
184
|
|
|
216
|
-
|
|
185
|
+
</details>
|
|
186
|
+
|
|
187
|
+
<details>
|
|
188
|
+
<summary><b>Idempotency</b></summary>
|
|
217
189
|
|
|
218
|
-
|
|
190
|
+
Deduplication is built-in. For distributed systems, provide a Redis store:
|
|
219
191
|
|
|
220
192
|
```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
|
-
})
|
|
193
|
+
const redisStore = {
|
|
194
|
+
has: (id) => redis.exists(`idem:${id}`),
|
|
195
|
+
add: (id, ttl) => redis.set(`idem:${id}`, '1', 'PX', ttl),
|
|
196
|
+
}
|
|
230
197
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
total: 99.99,
|
|
198
|
+
await consumeJetStreamEvents({
|
|
199
|
+
// ...
|
|
200
|
+
idempotencyStore: redisStore,
|
|
235
201
|
})
|
|
236
202
|
```
|
|
237
203
|
|
|
238
|
-
|
|
204
|
+
</details>
|
|
239
205
|
|
|
240
|
-
|
|
241
|
-
|
|
206
|
+
<details>
|
|
207
|
+
<summary><b>Dead Letter Queue</b></summary>
|
|
208
|
+
|
|
209
|
+
Invalid messages are quarantined, not lost:
|
|
242
210
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
211
|
+
```typescript
|
|
212
|
+
await consumeJetStreamEvents({
|
|
213
|
+
// ...
|
|
214
|
+
quarantineTopic: 'events.quarantine',
|
|
215
|
+
errorTopic: 'events.errors',
|
|
246
216
|
})
|
|
247
217
|
```
|
|
248
218
|
|
|
249
|
-
|
|
219
|
+
</details>
|
|
250
220
|
|
|
251
|
-
|
|
221
|
+
<details>
|
|
222
|
+
<summary><b>HTTP Ingestion (Hono)</b></summary>
|
|
252
223
|
|
|
253
224
|
```typescript
|
|
254
225
|
import { Hono } from 'hono'
|
|
255
226
|
import { cloudEvents } from '@crossdelta/cloudevents'
|
|
256
227
|
|
|
257
228
|
const app = new Hono()
|
|
258
|
-
|
|
259
|
-
app.use('/events', cloudEvents({
|
|
260
|
-
discover: 'src/handlers/**/*.event.ts',
|
|
261
|
-
dlqEnabled: true,
|
|
262
|
-
}))
|
|
229
|
+
app.use('/events', cloudEvents({ discover: 'src/handlers/**/*.event.ts' }))
|
|
263
230
|
```
|
|
264
231
|
|
|
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 |
|
|
232
|
+
</details>
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## API
|
|
237
|
+
|
|
238
|
+
| Function | Purpose |
|
|
239
|
+
|----------|---------|
|
|
240
|
+
| `handleEvent(options, handler)` | Create a handler |
|
|
241
|
+
| `consumeJetStreamEvents(options)` | Consume with persistence |
|
|
242
|
+
| `consumeNatsEvents(options)` | Consume fire-and-forget |
|
|
243
|
+
| `publish(type, data)` | Publish event |
|
|
244
|
+
|
|
245
|
+
---
|
|
286
246
|
|
|
287
247
|
## License
|
|
288
248
|
|
|
@@ -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.2.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",
|