@crossdelta/cloudevents 0.3.4 → 0.4.0
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 +111 -17
- package/dist/domain/contract-helper.d.ts +34 -0
- package/dist/domain/contract-helper.js +29 -0
- package/dist/{src/domain → domain}/discovery.js +46 -6
- package/dist/{src/domain → domain}/handler-factory.d.ts +13 -2
- package/dist/{src/domain → domain}/handler-factory.js +12 -1
- package/dist/{src/domain → domain}/index.d.ts +1 -0
- package/dist/{src/domain → domain}/index.js +1 -0
- package/dist/{src/domain → domain}/types.d.ts +4 -1
- package/dist/{src/index.d.ts → index.d.ts} +3 -2
- package/dist/{src/index.js → index.js} +2 -1
- package/dist/{src/transports → transports}/nats/jetstream-consumer.d.ts +43 -5
- package/dist/{src/transports → transports}/nats/jetstream-consumer.js +46 -44
- package/package.json +6 -6
- /package/dist/{src/adapters → adapters}/cloudevents/cloudevents.d.ts +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/cloudevents.js +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/index.d.ts +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/index.js +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/parsers/binary-mode.d.ts +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/parsers/binary-mode.js +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/parsers/pubsub.d.ts +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/parsers/pubsub.js +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/parsers/raw-event.d.ts +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/parsers/raw-event.js +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/parsers/structured-mode.d.ts +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/parsers/structured-mode.js +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/types.d.ts +0 -0
- /package/dist/{src/adapters → adapters}/cloudevents/types.js +0 -0
- /package/dist/{src/domain → domain}/discovery.d.ts +0 -0
- /package/dist/{src/domain → domain}/types.js +0 -0
- /package/dist/{src/domain → domain}/validation.d.ts +0 -0
- /package/dist/{src/domain → domain}/validation.js +0 -0
- /package/dist/{src/infrastructure → infrastructure}/errors.d.ts +0 -0
- /package/dist/{src/infrastructure → infrastructure}/errors.js +0 -0
- /package/dist/{src/infrastructure → infrastructure}/index.d.ts +0 -0
- /package/dist/{src/infrastructure → infrastructure}/index.js +0 -0
- /package/dist/{src/infrastructure → infrastructure}/logging.d.ts +0 -0
- /package/dist/{src/infrastructure → infrastructure}/logging.js +0 -0
- /package/dist/{src/middlewares → middlewares}/cloudevents-middleware.d.ts +0 -0
- /package/dist/{src/middlewares → middlewares}/cloudevents-middleware.js +0 -0
- /package/dist/{src/middlewares → middlewares}/index.d.ts +0 -0
- /package/dist/{src/middlewares → middlewares}/index.js +0 -0
- /package/dist/{src/processing → processing}/dlq-safe.d.ts +0 -0
- /package/dist/{src/processing → processing}/dlq-safe.js +0 -0
- /package/dist/{src/processing → processing}/handler-cache.d.ts +0 -0
- /package/dist/{src/processing → processing}/handler-cache.js +0 -0
- /package/dist/{src/processing → processing}/idempotency.d.ts +0 -0
- /package/dist/{src/processing → processing}/idempotency.js +0 -0
- /package/dist/{src/processing → processing}/index.d.ts +0 -0
- /package/dist/{src/processing → processing}/index.js +0 -0
- /package/dist/{src/processing → processing}/validation.d.ts +0 -0
- /package/dist/{src/processing → processing}/validation.js +0 -0
- /package/dist/{src/publishing → publishing}/index.d.ts +0 -0
- /package/dist/{src/publishing → publishing}/index.js +0 -0
- /package/dist/{src/publishing → publishing}/nats.publisher.d.ts +0 -0
- /package/dist/{src/publishing → publishing}/nats.publisher.js +0 -0
- /package/dist/{src/publishing → publishing}/pubsub.publisher.d.ts +0 -0
- /package/dist/{src/publishing → publishing}/pubsub.publisher.js +0 -0
- /package/dist/{src/transports → transports}/nats/base-message-processor.d.ts +0 -0
- /package/dist/{src/transports → transports}/nats/base-message-processor.js +0 -0
- /package/dist/{src/transports → transports}/nats/index.d.ts +0 -0
- /package/dist/{src/transports → transports}/nats/index.js +0 -0
- /package/dist/{src/transports → transports}/nats/jetstream-message-processor.d.ts +0 -0
- /package/dist/{src/transports → transports}/nats/jetstream-message-processor.js +0 -0
- /package/dist/{src/transports → transports}/nats/nats-consumer.d.ts +0 -0
- /package/dist/{src/transports → transports}/nats/nats-consumer.js +0 -0
- /package/dist/{src/transports → transports}/nats/nats-message-processor.d.ts +0 -0
- /package/dist/{src/transports → transports}/nats/nats-message-processor.js +0 -0
package/README.md
CHANGED
|
@@ -29,13 +29,14 @@ bun add @crossdelta/cloudevents zod@4
|
|
|
29
29
|
|
|
30
30
|
## Quick Start
|
|
31
31
|
|
|
32
|
-
**1. Create an event handler** (`src/
|
|
32
|
+
**1. Create an event handler** (`src/events/order-created.event.ts`):
|
|
33
33
|
|
|
34
34
|
```typescript
|
|
35
35
|
import { handleEvent } from '@crossdelta/cloudevents'
|
|
36
36
|
import { z } from 'zod'
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
// Export schema for mock generation
|
|
39
|
+
export const OrderCreatedSchema = z.object({
|
|
39
40
|
orderId: z.string(),
|
|
40
41
|
total: z.number(),
|
|
41
42
|
})
|
|
@@ -54,20 +55,30 @@ export default handleEvent(
|
|
|
54
55
|
)
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
**2.
|
|
58
|
+
**2. Ensure stream exists** (once, during setup):
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { ensureJetStreamStream } from '@crossdelta/cloudevents'
|
|
62
|
+
|
|
63
|
+
await ensureJetStreamStream({
|
|
64
|
+
stream: 'ORDERS',
|
|
65
|
+
subjects: ['orders.*'],
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**3. Start consuming:**
|
|
58
70
|
|
|
59
71
|
```typescript
|
|
60
72
|
import { consumeJetStreamEvents } from '@crossdelta/cloudevents'
|
|
61
73
|
|
|
62
74
|
await consumeJetStreamEvents({
|
|
63
|
-
stream: 'ORDERS',
|
|
64
|
-
subjects: ['orders.*'],
|
|
75
|
+
stream: 'ORDERS',
|
|
65
76
|
consumer: 'my-service',
|
|
66
|
-
discover: './src/
|
|
77
|
+
discover: './src/events/**/*.event.ts',
|
|
67
78
|
})
|
|
68
79
|
```
|
|
69
80
|
|
|
70
|
-
**
|
|
81
|
+
**4. Publish from another service:**
|
|
71
82
|
|
|
72
83
|
```typescript
|
|
73
84
|
import { publish } from '@crossdelta/cloudevents'
|
|
@@ -118,7 +129,7 @@ export default handleEvent(
|
|
|
118
129
|
Drop a `*.event.ts` file anywhere — it's auto-registered:
|
|
119
130
|
|
|
120
131
|
```typescript
|
|
121
|
-
// src/
|
|
132
|
+
// src/events/user-signup.event.ts
|
|
122
133
|
import { z } from 'zod'
|
|
123
134
|
|
|
124
135
|
const UserSignupSchema = z.object({
|
|
@@ -146,21 +157,38 @@ export default handleEvent(
|
|
|
146
157
|
await publish('orders.created', orderData)
|
|
147
158
|
```
|
|
148
159
|
|
|
160
|
+
### Stream Setup
|
|
161
|
+
|
|
162
|
+
Create the stream once during infrastructure setup:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { ensureJetStreamStream } from '@crossdelta/cloudevents'
|
|
166
|
+
|
|
167
|
+
await ensureJetStreamStream({
|
|
168
|
+
stream: 'ORDERS',
|
|
169
|
+
subjects: ['orders.>'],
|
|
170
|
+
config: {
|
|
171
|
+
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days retention
|
|
172
|
+
replicas: 3, // For HA
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
```
|
|
176
|
+
|
|
149
177
|
### Consuming
|
|
150
178
|
|
|
151
179
|
```typescript
|
|
152
180
|
// JetStream (recommended) — persistent, retries, exactly-once
|
|
153
181
|
await consumeJetStreamEvents({
|
|
154
182
|
stream: 'ORDERS',
|
|
155
|
-
subjects: ['orders.*'],
|
|
156
183
|
consumer: 'billing',
|
|
157
|
-
discover: './src/
|
|
184
|
+
discover: './src/events/**/*.event.ts',
|
|
185
|
+
filterSubjects: ['orders.created', 'orders.updated'], // Optional: filter at consumer level
|
|
158
186
|
})
|
|
159
187
|
|
|
160
188
|
// Core NATS — fire-and-forget, simpler
|
|
161
189
|
await consumeNatsEvents({
|
|
162
190
|
subjects: ['notifications.*'],
|
|
163
|
-
discover: './src/
|
|
191
|
+
discover: './src/events/**/*.event.ts',
|
|
164
192
|
})
|
|
165
193
|
```
|
|
166
194
|
|
|
@@ -176,21 +204,40 @@ NATS_USER=myuser # optional
|
|
|
176
204
|
NATS_PASSWORD=mypass # optional
|
|
177
205
|
```
|
|
178
206
|
|
|
207
|
+
### Stream Options
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
await ensureJetStreamStream({
|
|
211
|
+
// Required
|
|
212
|
+
stream: 'ORDERS',
|
|
213
|
+
subjects: ['orders.>'],
|
|
214
|
+
|
|
215
|
+
// Optional
|
|
216
|
+
servers: 'nats://localhost:4222',
|
|
217
|
+
config: {
|
|
218
|
+
maxAge: 7 * 24 * 60 * 60 * 1000, // Message retention (ms)
|
|
219
|
+
replicas: 1, // Replication factor
|
|
220
|
+
storage: 'file', // 'file' or 'memory'
|
|
221
|
+
retention: 'limits', // 'limits', 'interest', or 'workqueue'
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
```
|
|
225
|
+
|
|
179
226
|
### Consumer Options
|
|
180
227
|
|
|
181
228
|
```typescript
|
|
182
229
|
await consumeJetStreamEvents({
|
|
183
230
|
// Required
|
|
184
231
|
stream: 'ORDERS',
|
|
185
|
-
subjects: ['orders.*'],
|
|
186
232
|
consumer: 'my-service',
|
|
187
|
-
discover: './src/
|
|
233
|
+
discover: './src/events/**/*.event.ts',
|
|
188
234
|
|
|
189
235
|
// Optional
|
|
190
236
|
servers: 'nats://localhost:4222',
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
237
|
+
filterSubjects: ['orders.created'], // Filter specific subjects
|
|
238
|
+
maxDeliver: 5, // Retry attempts
|
|
239
|
+
ackWait: 30_000, // Timeout per attempt (ms)
|
|
240
|
+
quarantineTopic: 'dlq', // For poison messages
|
|
194
241
|
})
|
|
195
242
|
```
|
|
196
243
|
|
|
@@ -198,6 +245,51 @@ await consumeJetStreamEvents({
|
|
|
198
245
|
|
|
199
246
|
## Advanced Features
|
|
200
247
|
|
|
248
|
+
<details>
|
|
249
|
+
<summary><b>Shared Contracts</b></summary>
|
|
250
|
+
|
|
251
|
+
When multiple services consume the same event, use `createContract` to create shared event contracts:
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
// packages/contracts/src/schemas/order-created.schema.ts
|
|
255
|
+
import { createContract } from '@crossdelta/cloudevents'
|
|
256
|
+
import { z } from 'zod'
|
|
257
|
+
|
|
258
|
+
export const OrderCreatedSchema = z.object({
|
|
259
|
+
orderId: z.string(),
|
|
260
|
+
total: z.number(),
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
export type OrderCreatedData = z.infer<typeof OrderCreatedSchema>
|
|
264
|
+
|
|
265
|
+
export const OrderCreatedContract = createContract({
|
|
266
|
+
type: 'orders.created',
|
|
267
|
+
schema: OrderCreatedSchema,
|
|
268
|
+
})
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Then import and use in handlers:
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
import { handleEvent } from '@crossdelta/cloudevents'
|
|
275
|
+
import { OrderCreatedContract } from '@my-org/contracts'
|
|
276
|
+
|
|
277
|
+
export default handleEvent(
|
|
278
|
+
OrderCreatedContract,
|
|
279
|
+
async (data) => {
|
|
280
|
+
// data is typed as OrderCreatedData
|
|
281
|
+
console.log(data.orderId)
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Benefits:
|
|
287
|
+
- Single source of truth for event schemas
|
|
288
|
+
- No schema duplication
|
|
289
|
+
- Type safety across services
|
|
290
|
+
|
|
291
|
+
</details>
|
|
292
|
+
|
|
201
293
|
<details>
|
|
202
294
|
<summary><b>Multi-Tenancy</b></summary>
|
|
203
295
|
|
|
@@ -270,7 +362,7 @@ import { Hono } from 'hono'
|
|
|
270
362
|
import { cloudEvents } from '@crossdelta/cloudevents'
|
|
271
363
|
|
|
272
364
|
const app = new Hono()
|
|
273
|
-
app.use('/events', cloudEvents({ discover: 'src/
|
|
365
|
+
app.use('/events', cloudEvents({ discover: 'src/events/**/*.event.ts' }))
|
|
274
366
|
```
|
|
275
367
|
|
|
276
368
|
</details>
|
|
@@ -282,6 +374,8 @@ app.use('/events', cloudEvents({ discover: 'src/handlers/**/*.event.ts' }))
|
|
|
282
374
|
| Function | Purpose |
|
|
283
375
|
|----------|---------|
|
|
284
376
|
| `handleEvent(options, handler)` | Create a handler |
|
|
377
|
+
| `createContract(options)` | Create shared event contract |
|
|
378
|
+
| `ensureJetStreamStream(options)` | Create/update JetStream stream |
|
|
285
379
|
| `consumeJetStreamEvents(options)` | Consume with persistence |
|
|
286
380
|
| `consumeNatsEvents(options)` | Consume fire-and-forget |
|
|
287
381
|
| `publish(type, data)` | Publish event |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ZodTypeAny } from 'zod';
|
|
2
|
+
import type { HandleEventOptions } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a type-safe event contract for Advanced Mode
|
|
5
|
+
*
|
|
6
|
+
* This helper ensures proper type inference when using contracts
|
|
7
|
+
* with handleEvent across package boundaries.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { createContract } from '@crossdelta/cloudevents'
|
|
12
|
+
* import { z } from 'zod'
|
|
13
|
+
*
|
|
14
|
+
* export const OrderCreatedContract = createContract({
|
|
15
|
+
* type: 'orders.created',
|
|
16
|
+
* schema: z.object({
|
|
17
|
+
* orderId: z.string(),
|
|
18
|
+
* total: z.number(),
|
|
19
|
+
* }),
|
|
20
|
+
* })
|
|
21
|
+
*
|
|
22
|
+
* // Type is inferred: { orderId: string, total: number }
|
|
23
|
+
* export type OrderCreatedData = typeof OrderCreatedContract._schema._output
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare function createContract<TSchema extends ZodTypeAny>(options: {
|
|
27
|
+
type: string;
|
|
28
|
+
schema: TSchema;
|
|
29
|
+
match?: HandleEventOptions['match'];
|
|
30
|
+
safeParse?: boolean;
|
|
31
|
+
tenantId?: string | string[];
|
|
32
|
+
}): HandleEventOptions<TSchema> & {
|
|
33
|
+
_schema: TSchema;
|
|
34
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a type-safe event contract for Advanced Mode
|
|
3
|
+
*
|
|
4
|
+
* This helper ensures proper type inference when using contracts
|
|
5
|
+
* with handleEvent across package boundaries.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { createContract } from '@crossdelta/cloudevents'
|
|
10
|
+
* import { z } from 'zod'
|
|
11
|
+
*
|
|
12
|
+
* export const OrderCreatedContract = createContract({
|
|
13
|
+
* type: 'orders.created',
|
|
14
|
+
* schema: z.object({
|
|
15
|
+
* orderId: z.string(),
|
|
16
|
+
* total: z.number(),
|
|
17
|
+
* }),
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* // Type is inferred: { orderId: string, total: number }
|
|
21
|
+
* export type OrderCreatedData = typeof OrderCreatedContract._schema._output
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function createContract(options) {
|
|
25
|
+
return {
|
|
26
|
+
...options,
|
|
27
|
+
_schema: options.schema,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { glob } from 'glob';
|
|
4
5
|
import { logger } from '../infrastructure';
|
|
@@ -13,17 +14,28 @@ const getSearchDirectories = () => {
|
|
|
13
14
|
try {
|
|
14
15
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
directories.add(currentDir);
|
|
17
|
+
// For monorepo tests: also check packages/cloudevents if we're in dist/
|
|
18
|
+
if (currentDir.includes('/dist/')) {
|
|
19
|
+
const pkgRoot = currentDir.split('/dist/')[0];
|
|
20
|
+
if (pkgRoot)
|
|
21
|
+
directories.add(pkgRoot);
|
|
22
|
+
}
|
|
16
23
|
}
|
|
17
24
|
catch {
|
|
18
25
|
// Ignore resolution errors; fallback to process.cwd()
|
|
19
26
|
}
|
|
20
|
-
|
|
27
|
+
// Filter out directories that don't exist (e.g., during hot-reload)
|
|
28
|
+
return [...directories].filter((dir) => existsSync(dir));
|
|
21
29
|
};
|
|
22
30
|
/**
|
|
23
31
|
* Loads and validates handlers from a TypeScript/JavaScript file.
|
|
24
32
|
*/
|
|
25
33
|
const loadHandlers = async (filePath, filter) => {
|
|
26
34
|
try {
|
|
35
|
+
// Check if file exists before import to avoid ENOENT during hot-reload
|
|
36
|
+
if (!existsSync(filePath)) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
27
39
|
const module = await import(filePath);
|
|
28
40
|
return Object.entries(module)
|
|
29
41
|
.filter(([name, handler]) => isValidHandler(handler) && (!filter || filter(name, handler)))
|
|
@@ -78,19 +90,47 @@ const expandPatternVariants = (pattern, preferCompiled) => {
|
|
|
78
90
|
const ordered = preferCompiled ? [...compiledVariants, basePattern] : [basePattern, ...compiledVariants];
|
|
79
91
|
return [...new Set(ordered)];
|
|
80
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* Check if a directory path should be scanned (exists and is accessible)
|
|
95
|
+
*/
|
|
96
|
+
const shouldScanDirectory = (basePath, variant) => {
|
|
97
|
+
if (!existsSync(basePath)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
// For patterns with directory prefixes like "dist/...", check if prefix dir exists
|
|
101
|
+
const dirPrefix = variant.match(/^([^*{[]+)\//)?.[1];
|
|
102
|
+
if (dirPrefix) {
|
|
103
|
+
const fullDirPath = join(basePath, dirPrefix);
|
|
104
|
+
return existsSync(fullDirPath);
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
};
|
|
81
108
|
const discoverFiles = async (pattern, basePath, preferCompiled) => {
|
|
82
|
-
|
|
109
|
+
// Don't add src/dist prefix for test patterns or absolute paths
|
|
110
|
+
const isTestPattern = pattern.startsWith('test/');
|
|
111
|
+
const prefixedPattern = isTestPattern ? pattern : `{src,dist,build,lib,out}/${pattern}`;
|
|
83
112
|
const patterns = preferCompiled ? [prefixedPattern, pattern] : [pattern, prefixedPattern];
|
|
84
113
|
const allFiles = [];
|
|
85
114
|
for (const globPattern of patterns) {
|
|
86
115
|
const variants = expandPatternVariants(globPattern, preferCompiled);
|
|
87
116
|
for (const variant of variants) {
|
|
88
117
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
118
|
+
// Check if directory should be scanned to avoid scandir errors during hot-reload
|
|
119
|
+
if (!shouldScanDirectory(basePath, variant)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const files = await glob(variant, {
|
|
123
|
+
cwd: basePath,
|
|
124
|
+
absolute: true,
|
|
125
|
+
nodir: true,
|
|
126
|
+
windowsPathsNoEscape: true,
|
|
127
|
+
});
|
|
128
|
+
// Filter out files that no longer exist (race condition during hot-reload)
|
|
129
|
+
const existingFiles = files.filter((f) => existsSync(f));
|
|
130
|
+
allFiles.push(...existingFiles);
|
|
91
131
|
}
|
|
92
132
|
catch {
|
|
93
|
-
// Ignore errors
|
|
133
|
+
// Ignore errors silently - directory might have been deleted during hot-reload
|
|
94
134
|
}
|
|
95
135
|
}
|
|
96
136
|
}
|
|
@@ -5,7 +5,9 @@ import type { HandleEventOptions, HandlerConstructor } from './types';
|
|
|
5
5
|
* Creates an event handler using the handleEvent pattern
|
|
6
6
|
* Compatible with the new middleware system
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Supports both inline schemas (Basic Mode) and shared contracts (Advanced Mode).
|
|
9
|
+
*
|
|
10
|
+
* @example Basic Mode - Data-only schema inline
|
|
9
11
|
* ```typescript
|
|
10
12
|
* export default handleEvent({
|
|
11
13
|
* type: 'orderboss.orders.created',
|
|
@@ -15,6 +17,15 @@ import type { HandleEventOptions, HandlerConstructor } from './types';
|
|
|
15
17
|
* })
|
|
16
18
|
* ```
|
|
17
19
|
*
|
|
20
|
+
* @example Advanced Mode - With shared contract
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { OrderCreatedContract } from '@orderboss/contracts'
|
|
23
|
+
*
|
|
24
|
+
* export default handleEvent(OrderCreatedContract, async (data) => {
|
|
25
|
+
* console.log('Order created:', data.orderId)
|
|
26
|
+
* })
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
18
29
|
* @example With tenant filtering
|
|
19
30
|
* ```typescript
|
|
20
31
|
* export default handleEvent({
|
|
@@ -26,7 +37,7 @@ import type { HandleEventOptions, HandlerConstructor } from './types';
|
|
|
26
37
|
* })
|
|
27
38
|
* ```
|
|
28
39
|
*/
|
|
29
|
-
export declare function handleEvent<TSchema extends ZodTypeAny>(schemaOrOptions: TSchema | HandleEventOptions<TSchema
|
|
40
|
+
export declare function handleEvent<TSchema extends ZodTypeAny>(schemaOrOptions: TSchema | HandleEventOptions<TSchema> | HandleEventOptions, handler: (payload: TSchema['_output'], context?: EventContext) => Promise<void> | void, eventType?: string): HandlerConstructor;
|
|
30
41
|
/**
|
|
31
42
|
* Creates an event schema with type inference
|
|
32
43
|
* Automatically enforces the presence of a 'type' field
|
|
@@ -67,7 +67,9 @@ function combineMatchers(matchers) {
|
|
|
67
67
|
* Creates an event handler using the handleEvent pattern
|
|
68
68
|
* Compatible with the new middleware system
|
|
69
69
|
*
|
|
70
|
-
*
|
|
70
|
+
* Supports both inline schemas (Basic Mode) and shared contracts (Advanced Mode).
|
|
71
|
+
*
|
|
72
|
+
* @example Basic Mode - Data-only schema inline
|
|
71
73
|
* ```typescript
|
|
72
74
|
* export default handleEvent({
|
|
73
75
|
* type: 'orderboss.orders.created',
|
|
@@ -77,6 +79,15 @@ function combineMatchers(matchers) {
|
|
|
77
79
|
* })
|
|
78
80
|
* ```
|
|
79
81
|
*
|
|
82
|
+
* @example Advanced Mode - With shared contract
|
|
83
|
+
* ```typescript
|
|
84
|
+
* import { OrderCreatedContract } from '@orderboss/contracts'
|
|
85
|
+
*
|
|
86
|
+
* export default handleEvent(OrderCreatedContract, async (data) => {
|
|
87
|
+
* console.log('Order created:', data.orderId)
|
|
88
|
+
* })
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
80
91
|
* @example With tenant filtering
|
|
81
92
|
* ```typescript
|
|
82
93
|
* export default handleEvent({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { discoverHandlers } from './discovery';
|
|
2
|
+
export { createContract } from './contract-helper';
|
|
2
3
|
export { eventSchema, handleEvent } from './handler-factory';
|
|
3
4
|
export type { EnrichedEvent, EventHandler, HandleEventOptions, HandlerConstructor, HandlerMetadata, IdempotencyStore, InferEventData, MatchFn, RoutingConfig, } from './types';
|
|
4
5
|
export type { HandlerValidationError, ValidationErrorDetail } from './validation';
|
|
@@ -45,8 +45,11 @@ export type HandlerMetadata<S extends ZodTypeAny> = {
|
|
|
45
45
|
};
|
|
46
46
|
/**
|
|
47
47
|
* Options for creating event handlers
|
|
48
|
+
*
|
|
49
|
+
* Note: In Zod v4, we use a more relaxed schema constraint to allow
|
|
50
|
+
* contracts defined in external packages to work correctly.
|
|
48
51
|
*/
|
|
49
|
-
export interface HandleEventOptions<S
|
|
52
|
+
export interface HandleEventOptions<S = ZodTypeAny> {
|
|
50
53
|
schema: S;
|
|
51
54
|
type?: string;
|
|
52
55
|
match?: MatchFn<unknown>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export type { EventContext } from './adapters/cloudevents';
|
|
2
2
|
export { parseEventFromContext } from './adapters/cloudevents';
|
|
3
|
-
export
|
|
3
|
+
export { createContract } from './domain';
|
|
4
|
+
export type { EnrichedEvent, HandleEventOptions, IdempotencyStore, InferEventData, RoutingConfig } from './domain';
|
|
4
5
|
export { eventSchema, extractTypeFromSchema, handleEvent } from './domain';
|
|
5
6
|
export { clearHandlerCache, cloudEvents } from './middlewares';
|
|
6
7
|
export { checkAndMarkProcessed, createInMemoryIdempotencyStore, getDefaultIdempotencyStore, resetDefaultIdempotencyStore, } from './processing/idempotency';
|
|
7
8
|
export * from './publishing';
|
|
8
|
-
export { consumeJetStreamEvents, consumeNatsEvents } from './transports/nats';
|
|
9
|
+
export { consumeJetStreamEvents, consumeNatsEvents, ensureJetStreamStream } from './transports/nats';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { parseEventFromContext } from './adapters/cloudevents';
|
|
2
|
+
export { createContract } from './domain';
|
|
2
3
|
export { eventSchema, extractTypeFromSchema, handleEvent } from './domain';
|
|
3
4
|
export { clearHandlerCache, cloudEvents } from './middlewares';
|
|
4
5
|
export { checkAndMarkProcessed, createInMemoryIdempotencyStore, getDefaultIdempotencyStore, resetDefaultIdempotencyStore, } from './processing/idempotency';
|
|
5
6
|
export * from './publishing';
|
|
6
|
-
export { consumeJetStreamEvents, consumeNatsEvents } from './transports/nats';
|
|
7
|
+
export { consumeJetStreamEvents, consumeNatsEvents, ensureJetStreamStream } from './transports/nats';
|
|
@@ -13,21 +13,43 @@ export interface StreamConfig {
|
|
|
13
13
|
replicas?: number;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* JetStream
|
|
16
|
+
* Options for creating/ensuring a JetStream stream exists
|
|
17
17
|
*/
|
|
18
|
-
export interface
|
|
18
|
+
export interface JetStreamStreamOptions {
|
|
19
19
|
/** NATS server URL. Defaults to NATS_URL env or nats://localhost:4222 */
|
|
20
20
|
servers?: string;
|
|
21
21
|
/** NATS username for authentication (defaults to NATS_USER env var) */
|
|
22
22
|
user?: string;
|
|
23
23
|
/** NATS password for authentication (defaults to NATS_PASSWORD env var) */
|
|
24
24
|
pass?: string;
|
|
25
|
-
/** JetStream stream name
|
|
25
|
+
/** JetStream stream name */
|
|
26
26
|
stream: string;
|
|
27
27
|
/** Subjects to bind to the stream (e.g., ['orders.>', 'payments.>']) */
|
|
28
28
|
subjects: string[];
|
|
29
|
+
/** Stream configuration */
|
|
30
|
+
config?: StreamConfig;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* JetStream consumer configuration
|
|
34
|
+
*/
|
|
35
|
+
export interface JetStreamConsumerOptions extends Pick<CloudEventsOptions, 'quarantineTopic' | 'errorTopic' | 'projectId' | 'source'> {
|
|
36
|
+
/** NATS server URL. Defaults to NATS_URL env or nats://localhost:4222 */
|
|
37
|
+
servers?: string;
|
|
38
|
+
/** NATS username for authentication (defaults to NATS_USER env var) */
|
|
39
|
+
user?: string;
|
|
40
|
+
/** NATS password for authentication (defaults to NATS_PASSWORD env var) */
|
|
41
|
+
pass?: string;
|
|
42
|
+
/** JetStream stream name (must already exist or use ensureJetStreamStream first) */
|
|
43
|
+
stream: string;
|
|
29
44
|
/** Durable consumer name. Required for persistence across restarts */
|
|
30
45
|
consumer: string;
|
|
46
|
+
/**
|
|
47
|
+
* Optional filter subjects for this consumer.
|
|
48
|
+
* If specified, consumer only receives messages matching these subjects.
|
|
49
|
+
* If omitted, consumer receives all messages from the stream.
|
|
50
|
+
* @example ['orders.created', 'orders.updated']
|
|
51
|
+
*/
|
|
52
|
+
filterSubjects?: string[];
|
|
31
53
|
/** Glob pattern to discover event handlers */
|
|
32
54
|
discover: string;
|
|
33
55
|
/**
|
|
@@ -51,8 +73,6 @@ export interface JetStreamConsumerOptions extends Pick<CloudEventsOptions, 'quar
|
|
|
51
73
|
* @default 3
|
|
52
74
|
*/
|
|
53
75
|
maxDeliver?: number;
|
|
54
|
-
/** Stream configuration (only used when auto-creating) */
|
|
55
|
-
streamConfig?: StreamConfig;
|
|
56
76
|
/**
|
|
57
77
|
* Idempotency store for deduplication. Defaults to in-memory store.
|
|
58
78
|
* Pass `false` to disable idempotency checks entirely.
|
|
@@ -64,6 +84,24 @@ export interface JetStreamConsumerOptions extends Pick<CloudEventsOptions, 'quar
|
|
|
64
84
|
*/
|
|
65
85
|
idempotencyTtl?: number;
|
|
66
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Ensures a JetStream stream exists with the given configuration.
|
|
89
|
+
* This is typically called once during application startup or in infrastructure setup.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* // In infrastructure/setup code:
|
|
94
|
+
* await ensureJetStreamStream({
|
|
95
|
+
* stream: 'ORDERS',
|
|
96
|
+
* subjects: ['orders.>'],
|
|
97
|
+
* config: {
|
|
98
|
+
* maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
99
|
+
* replicas: 3
|
|
100
|
+
* }
|
|
101
|
+
* })
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export declare function ensureJetStreamStream(options: JetStreamStreamOptions): Promise<void>;
|
|
67
105
|
/**
|
|
68
106
|
* Consume CloudEvents from NATS JetStream with persistence and guaranteed delivery.
|
|
69
107
|
*
|
|
@@ -14,15 +14,6 @@ import { processHandler } from '../../processing/handler-cache';
|
|
|
14
14
|
import { checkAndMarkProcessed, getDefaultIdempotencyStore } from '../../processing/idempotency';
|
|
15
15
|
import { createJetStreamMessageProcessor } from './jetstream-message-processor';
|
|
16
16
|
const sc = StringCodec();
|
|
17
|
-
// Use globalThis to persist across hot-reloads
|
|
18
|
-
const JETSTREAM_REGISTRY_KEY = '__crossdelta_jetstream_consumers__';
|
|
19
|
-
function getJetStreamRegistry() {
|
|
20
|
-
if (!globalThis[JETSTREAM_REGISTRY_KEY]) {
|
|
21
|
-
;
|
|
22
|
-
globalThis[JETSTREAM_REGISTRY_KEY] = new Map();
|
|
23
|
-
}
|
|
24
|
-
return globalThis[JETSTREAM_REGISTRY_KEY];
|
|
25
|
-
}
|
|
26
17
|
// Default stream configuration
|
|
27
18
|
const DEFAULT_STREAM_CONFIG = {
|
|
28
19
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
|
|
@@ -30,7 +21,40 @@ const DEFAULT_STREAM_CONFIG = {
|
|
|
30
21
|
replicas: 1,
|
|
31
22
|
};
|
|
32
23
|
/**
|
|
33
|
-
* Ensures stream exists with the given configuration
|
|
24
|
+
* Ensures a JetStream stream exists with the given configuration.
|
|
25
|
+
* This is typically called once during application startup or in infrastructure setup.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* // In infrastructure/setup code:
|
|
30
|
+
* await ensureJetStreamStream({
|
|
31
|
+
* stream: 'ORDERS',
|
|
32
|
+
* subjects: ['orders.>'],
|
|
33
|
+
* config: {
|
|
34
|
+
* maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
35
|
+
* replicas: 3
|
|
36
|
+
* }
|
|
37
|
+
* })
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export async function ensureJetStreamStream(options) {
|
|
41
|
+
const servers = options.servers ?? process.env.NATS_URL ?? 'nats://localhost:4222';
|
|
42
|
+
const user = options.user ?? process.env.NATS_USER;
|
|
43
|
+
const pass = options.pass ?? process.env.NATS_PASSWORD;
|
|
44
|
+
const nc = await connect({
|
|
45
|
+
servers,
|
|
46
|
+
...(user && pass ? { user, pass } : {}),
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
const jsm = await nc.jetstreamManager();
|
|
50
|
+
await ensureStream(jsm, options.stream, options.subjects, options.config);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await nc.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ensures stream exists with the given configuration (internal helper)
|
|
34
58
|
*/
|
|
35
59
|
async function ensureStream(jsm, name, subjects, config = {}) {
|
|
36
60
|
const streamConfig = { ...DEFAULT_STREAM_CONFIG, ...config };
|
|
@@ -89,24 +113,12 @@ async function ensureConsumer(jsm, streamName, consumerName, options) {
|
|
|
89
113
|
// replay_policy defaults to 'instant', no need to specify explicitly
|
|
90
114
|
ack_wait: (options.ackWait ?? 30_000) * 1_000_000, // Convert to nanoseconds
|
|
91
115
|
max_deliver: options.maxDeliver ?? 3,
|
|
116
|
+
// Filter subjects at consumer level (optional)
|
|
117
|
+
filter_subjects: options.filterSubjects,
|
|
92
118
|
});
|
|
93
119
|
logger.info(`[jetstream] created durable consumer ${consumerName} on stream ${streamName}`);
|
|
94
120
|
}
|
|
95
121
|
}
|
|
96
|
-
/**
|
|
97
|
-
* Cleanup function to close a JetStream consumer
|
|
98
|
-
*/
|
|
99
|
-
async function cleanupJetStreamConsumer(name) {
|
|
100
|
-
const registry = getJetStreamRegistry();
|
|
101
|
-
const consumer = registry.get(name);
|
|
102
|
-
if (consumer) {
|
|
103
|
-
logger.info(`[${name}] cleaning up JetStream consumer...`);
|
|
104
|
-
consumer.abortController.abort();
|
|
105
|
-
await consumer.messages.close();
|
|
106
|
-
await consumer.connection.drain();
|
|
107
|
-
registry.delete(name);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
122
|
/**
|
|
111
123
|
* Consume CloudEvents from NATS JetStream with persistence and guaranteed delivery.
|
|
112
124
|
*
|
|
@@ -128,13 +140,11 @@ async function cleanupJetStreamConsumer(name) {
|
|
|
128
140
|
* ```
|
|
129
141
|
*/
|
|
130
142
|
export async function consumeJetStreamEvents(options) {
|
|
131
|
-
const servers = options.servers ?? process.env.NATS_URL ?? 'nats://localhost:4222';
|
|
132
143
|
const name = options.consumer;
|
|
144
|
+
const servers = options.servers ?? process.env.NATS_URL ?? 'nats://localhost:4222';
|
|
133
145
|
// Authentication (from options or env vars)
|
|
134
146
|
const user = options.user ?? process.env.NATS_USER;
|
|
135
147
|
const pass = options.pass ?? process.env.NATS_PASSWORD;
|
|
136
|
-
// Cleanup existing consumer (handles hot-reload)
|
|
137
|
-
await cleanupJetStreamConsumer(name);
|
|
138
148
|
// 1) Discover handlers
|
|
139
149
|
const handlerConstructors = await discoverHandlers(options.discover);
|
|
140
150
|
const processedHandlers = handlerConstructors
|
|
@@ -151,19 +161,14 @@ export async function consumeJetStreamEvents(options) {
|
|
|
151
161
|
// 3) Setup JetStream
|
|
152
162
|
const jsm = await nc.jetstreamManager();
|
|
153
163
|
const js = nc.jetstream();
|
|
154
|
-
// 4) Ensure stream
|
|
155
|
-
await ensureStream(jsm, options.stream, options.subjects, options.streamConfig);
|
|
156
|
-
// 5) Ensure durable consumer exists
|
|
164
|
+
// 4) Ensure durable consumer exists (stream must already exist)
|
|
157
165
|
await ensureConsumer(jsm, options.stream, name, options);
|
|
158
|
-
//
|
|
166
|
+
// 5) Get consumer and start consuming
|
|
159
167
|
const consumer = await js.consumers.get(options.stream, name);
|
|
160
168
|
const messages = await consumer.consume({
|
|
161
169
|
max_messages: options.maxMessages ?? 100,
|
|
162
170
|
});
|
|
163
171
|
logger.info(`[${name}] consuming from stream ${options.stream}`);
|
|
164
|
-
// Track for cleanup
|
|
165
|
-
const abortController = new AbortController();
|
|
166
|
-
getJetStreamRegistry().set(name, { messages, connection: nc, abortController });
|
|
167
172
|
const dlqEnabled = Boolean(options.quarantineTopic || options.errorTopic);
|
|
168
173
|
// Setup idempotency store
|
|
169
174
|
const idempotencyStore = options.idempotencyStore === false ? null : (options.idempotencyStore ?? getDefaultIdempotencyStore());
|
|
@@ -206,18 +211,15 @@ export async function consumeJetStreamEvents(options) {
|
|
|
206
211
|
await handleUnhandledProcessingError(msg, error);
|
|
207
212
|
}
|
|
208
213
|
};
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
await processSingleMessage(msg);
|
|
214
|
+
(async () => {
|
|
215
|
+
try {
|
|
216
|
+
for await (const msg of messages) {
|
|
217
|
+
await processSingleMessage(msg);
|
|
218
|
+
}
|
|
215
219
|
}
|
|
216
|
-
|
|
217
|
-
processMessages().catch((err) => {
|
|
218
|
-
if (!abortController.signal.aborted) {
|
|
220
|
+
catch (err) {
|
|
219
221
|
logger.error(`[${name}] message processing loop crashed`, err);
|
|
220
222
|
}
|
|
221
|
-
});
|
|
223
|
+
})();
|
|
222
224
|
return messages;
|
|
223
225
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossdelta/cloudevents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "CloudEvents toolkit for TypeScript - Zod validation, handler discovery, NATS JetStream",
|
|
5
5
|
"author": "crossdelta",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
"hono"
|
|
15
15
|
],
|
|
16
16
|
"type": "module",
|
|
17
|
-
"main": "dist/
|
|
18
|
-
"types": "dist/
|
|
17
|
+
"main": "dist/index.js",
|
|
18
|
+
"types": "dist/index.d.ts",
|
|
19
19
|
"files": [
|
|
20
|
-
"dist
|
|
20
|
+
"dist"
|
|
21
21
|
],
|
|
22
22
|
"exports": {
|
|
23
23
|
".": {
|
|
24
|
-
"import": "./dist/
|
|
25
|
-
"types": "./dist/
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts"
|
|
26
26
|
},
|
|
27
27
|
"./transports/nats": {
|
|
28
28
|
"import": "./dist/src/transports/nats/index.js",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|