@crossdelta/cloudevents 0.1.12 → 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 CHANGED
@@ -1,288 +1,248 @@
1
1
  # @crossdelta/cloudevents
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@crossdelta/cloudevents.svg)](https://www.npmjs.com/package/@crossdelta/cloudevents)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](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
- ┌─────────────────┐ NATS ┌─────────────────┐
13
- │ orders-service │ ──── publish ───► │ JetStream │
14
- └─────────────────┘ CloudEvent │ (persistent)
15
- └────────┬────────┘
16
-
17
- ┌───────────────────────┼───────────────────────┐
18
- ▼ ▼ ▼
19
- ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
20
- notifications billing │ │ analytics │
21
- │ service │ │ service │ │ service │
22
- └─────────────────┘ └─────────────────┘ └─────────────────┘
23
- consume consume consume
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
- ## Configuration
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
- For most deployments, just set the `NATS_URL` environment variable — no code changes needed.
28
+ ## Quick Start
54
29
 
55
- ## Getting Started
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: 'com.acme.orders.created',
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
- async handle(data) {
92
- console.log(`📧 Sending confirmation for order ${data.orderId}`)
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
- ### 3. Start Consuming (notifications-service)
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: ['com.acme.orders.>'],
107
- consumer: 'notifications-service',
108
- discover: './src/handlers/**/*.event.ts', // Auto-discovers handlers
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
- console.log('🎧 Listening for order events...')
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
- **That's it.** The handler is discovered automatically, events are persisted in JetStream, and failed messages are retried.
68
+ That's it. Handlers are auto-discovered, validated with Zod, and messages persist in JetStream.
115
69
 
116
- ## Consuming Events
70
+ ---
117
71
 
118
- ### NATS Core (Fire & Forget)
72
+ ## Why use this?
119
73
 
120
- For high-throughput scenarios where occasional message loss is acceptable:
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
- ```typescript
123
- import { consumeNatsEvents } from '@crossdelta/cloudevents'
81
+ ---
124
82
 
125
- await consumeNatsEvents({
126
- subject: 'telemetry.>',
127
- discover: './src/handlers/**/*.event.ts',
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
- ### NATS JetStream (Guaranteed Delivery)
132
-
133
- For critical business events that must not be lost:
99
+ ### Publishing
134
100
 
135
101
  ```typescript
136
- import { consumeJetStreamEvents } from '@crossdelta/cloudevents'
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
- // Consumer configuration
144
- consumer: 'billing-service',
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
- ## Handler Patterns
123
+ ---
162
124
 
163
- ### Data-Only Schema (Recommended)
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
- The simplest pattern — just validate the `data` field:
135
+ ### Consumer Options
166
136
 
167
137
  ```typescript
168
- export default handleEvent({
169
- type: 'com.example.users.created',
170
- schema: z.object({
171
- userId: z.string().uuid(),
172
- email: z.string().email(),
173
- }),
174
- async handle(data) {
175
- // data is typed as { userId: string, email: string }
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
- ### Full CloudEvent Schema
153
+ ---
154
+
155
+ ## Advanced Features
156
+
157
+ <details>
158
+ <summary><b>Multi-Tenancy</b></summary>
181
159
 
182
- When you need access to CloudEvent metadata:
160
+ Filter events by tenant:
183
161
 
184
162
  ```typescript
185
163
  export default handleEvent({
186
- type: 'com.example.orders.shipped',
187
- schema: z.object({
188
- data: z.object({
189
- orderId: z.string(),
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
- ### Conditional Matching
170
+ </details>
171
+
172
+ <details>
173
+ <summary><b>Custom Matching</b></summary>
202
174
 
203
- Process only specific events:
175
+ Add custom filter logic:
204
176
 
205
177
  ```typescript
206
178
  export default handleEvent({
207
- type: 'com.example.orders.*',
179
+ type: 'orders.created',
208
180
  schema: OrderSchema,
209
- match: (event) => event.data.region === 'EU',
210
- async handle(data) {
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
- ## Publishing
185
+ </details>
186
+
187
+ <details>
188
+ <summary><b>Idempotency</b></summary>
217
189
 
218
- ### To NATS
190
+ Deduplication is built-in. For distributed systems, provide a Redis store:
219
191
 
220
192
  ```typescript
221
- import { publishNatsEvent, publishNatsRawEvent } from '@crossdelta/cloudevents'
222
-
223
- // Structured CloudEvent
224
- await publishNatsEvent({
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
- // Raw data (auto-wrapped in CloudEvent)
232
- await publishNatsRawEvent('orders.created', {
233
- orderId: '123',
234
- total: 99.99,
198
+ await consumeJetStreamEvents({
199
+ // ...
200
+ idempotencyStore: redisStore,
235
201
  })
236
202
  ```
237
203
 
238
- ### To Google Pub/Sub
204
+ </details>
239
205
 
240
- ```typescript
241
- import { publishEvent } from '@crossdelta/cloudevents'
206
+ <details>
207
+ <summary><b>Dead Letter Queue</b></summary>
208
+
209
+ Invalid messages are quarantined, not lost:
242
210
 
243
- await publishEvent('orders-topic', {
244
- type: 'com.example.orders.created',
245
- data: { orderId: '123' },
211
+ ```typescript
212
+ await consumeJetStreamEvents({
213
+ // ...
214
+ quarantineTopic: 'events.quarantine',
215
+ errorTopic: 'events.errors',
246
216
  })
247
217
  ```
248
218
 
249
- ## Hono Middleware
219
+ </details>
250
220
 
251
- For HTTP-based event ingestion:
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
- ## API Reference
266
-
267
- | Function | Description |
268
- |----------|-------------|
269
- | `handleEvent(config)` | Create a discoverable event handler |
270
- | `consumeJetStreamEvents(options)` | Subscribe with guaranteed delivery |
271
- | `consumeNatsEvents(options)` | Subscribe with fire-and-forget |
272
- | `publishNatsEvent(event)` | Publish structured CloudEvent to NATS |
273
- | `publishNatsRawEvent(subject, data)` | Publish raw data to NATS |
274
- | `cloudEvents(options)` | Hono middleware for HTTP ingestion |
275
- | `clearHandlerCache()` | Reset handler discovery cache |
276
-
277
- ## Why JetStream?
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 && schema.shape && typeof schema.shape === 'object') {
7
- const shape = schema.shape;
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 undefined;
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
- schema = schemaOrOptions.schema;
25
- finalEventType = schemaOrOptions.type || eventType;
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'];
@@ -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: string, options: DlqOptions, error?: unknown) => Promise<void>;
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,3 +1,4 @@
1
1
  export * from './dlq-safe';
2
2
  export * from './handler-cache';
3
+ export * from './idempotency';
3
4
  export * from './validation';
@@ -1,3 +1,4 @@
1
1
  export * from './dlq-safe';
2
2
  export * from './handler-cache';
3
+ export * from './idempotency';
3
4
  export * from './validation';
@@ -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
- try {
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,17 +1,13 @@
1
1
  {
2
2
  "name": "@crossdelta/cloudevents",
3
- "version": "0.1.12",
4
- "description": "CloudEvents toolkit for TypeScript - handler discovery, DLQ-safe processing, NATS streaming",
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
- "repository": {
8
- "type": "git",
9
- "url": "https://github.com/orderboss/platform.git",
10
- "directory": "packages/cloudevents"
11
- },
12
7
  "keywords": [
13
8
  "cloudevents",
14
9
  "nats",
10
+ "zod",
15
11
  "pubsub",
16
12
  "event-driven",
17
13
  "typescript",