@crossdelta/cloudevents 0.3.4 → 0.4.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.
Files changed (68) hide show
  1. package/README.md +111 -17
  2. package/dist/domain/contract-helper.d.ts +34 -0
  3. package/dist/domain/contract-helper.js +29 -0
  4. package/dist/{src/domain → domain}/discovery.js +46 -6
  5. package/dist/{src/domain → domain}/handler-factory.d.ts +13 -2
  6. package/dist/{src/domain → domain}/handler-factory.js +12 -1
  7. package/dist/{src/domain → domain}/index.d.ts +1 -0
  8. package/dist/{src/domain → domain}/index.js +1 -0
  9. package/dist/{src/domain → domain}/types.d.ts +4 -1
  10. package/dist/{src/index.d.ts → index.d.ts} +3 -2
  11. package/dist/{src/index.js → index.js} +2 -1
  12. package/dist/{src/transports → transports}/nats/jetstream-consumer.d.ts +43 -5
  13. package/dist/{src/transports → transports}/nats/jetstream-consumer.js +46 -44
  14. package/package.json +6 -6
  15. /package/dist/{src/adapters → adapters}/cloudevents/cloudevents.d.ts +0 -0
  16. /package/dist/{src/adapters → adapters}/cloudevents/cloudevents.js +0 -0
  17. /package/dist/{src/adapters → adapters}/cloudevents/index.d.ts +0 -0
  18. /package/dist/{src/adapters → adapters}/cloudevents/index.js +0 -0
  19. /package/dist/{src/adapters → adapters}/cloudevents/parsers/binary-mode.d.ts +0 -0
  20. /package/dist/{src/adapters → adapters}/cloudevents/parsers/binary-mode.js +0 -0
  21. /package/dist/{src/adapters → adapters}/cloudevents/parsers/pubsub.d.ts +0 -0
  22. /package/dist/{src/adapters → adapters}/cloudevents/parsers/pubsub.js +0 -0
  23. /package/dist/{src/adapters → adapters}/cloudevents/parsers/raw-event.d.ts +0 -0
  24. /package/dist/{src/adapters → adapters}/cloudevents/parsers/raw-event.js +0 -0
  25. /package/dist/{src/adapters → adapters}/cloudevents/parsers/structured-mode.d.ts +0 -0
  26. /package/dist/{src/adapters → adapters}/cloudevents/parsers/structured-mode.js +0 -0
  27. /package/dist/{src/adapters → adapters}/cloudevents/types.d.ts +0 -0
  28. /package/dist/{src/adapters → adapters}/cloudevents/types.js +0 -0
  29. /package/dist/{src/domain → domain}/discovery.d.ts +0 -0
  30. /package/dist/{src/domain → domain}/types.js +0 -0
  31. /package/dist/{src/domain → domain}/validation.d.ts +0 -0
  32. /package/dist/{src/domain → domain}/validation.js +0 -0
  33. /package/dist/{src/infrastructure → infrastructure}/errors.d.ts +0 -0
  34. /package/dist/{src/infrastructure → infrastructure}/errors.js +0 -0
  35. /package/dist/{src/infrastructure → infrastructure}/index.d.ts +0 -0
  36. /package/dist/{src/infrastructure → infrastructure}/index.js +0 -0
  37. /package/dist/{src/infrastructure → infrastructure}/logging.d.ts +0 -0
  38. /package/dist/{src/infrastructure → infrastructure}/logging.js +0 -0
  39. /package/dist/{src/middlewares → middlewares}/cloudevents-middleware.d.ts +0 -0
  40. /package/dist/{src/middlewares → middlewares}/cloudevents-middleware.js +0 -0
  41. /package/dist/{src/middlewares → middlewares}/index.d.ts +0 -0
  42. /package/dist/{src/middlewares → middlewares}/index.js +0 -0
  43. /package/dist/{src/processing → processing}/dlq-safe.d.ts +0 -0
  44. /package/dist/{src/processing → processing}/dlq-safe.js +0 -0
  45. /package/dist/{src/processing → processing}/handler-cache.d.ts +0 -0
  46. /package/dist/{src/processing → processing}/handler-cache.js +0 -0
  47. /package/dist/{src/processing → processing}/idempotency.d.ts +0 -0
  48. /package/dist/{src/processing → processing}/idempotency.js +0 -0
  49. /package/dist/{src/processing → processing}/index.d.ts +0 -0
  50. /package/dist/{src/processing → processing}/index.js +0 -0
  51. /package/dist/{src/processing → processing}/validation.d.ts +0 -0
  52. /package/dist/{src/processing → processing}/validation.js +0 -0
  53. /package/dist/{src/publishing → publishing}/index.d.ts +0 -0
  54. /package/dist/{src/publishing → publishing}/index.js +0 -0
  55. /package/dist/{src/publishing → publishing}/nats.publisher.d.ts +0 -0
  56. /package/dist/{src/publishing → publishing}/nats.publisher.js +0 -0
  57. /package/dist/{src/publishing → publishing}/pubsub.publisher.d.ts +0 -0
  58. /package/dist/{src/publishing → publishing}/pubsub.publisher.js +0 -0
  59. /package/dist/{src/transports → transports}/nats/base-message-processor.d.ts +0 -0
  60. /package/dist/{src/transports → transports}/nats/base-message-processor.js +0 -0
  61. /package/dist/{src/transports → transports}/nats/index.d.ts +0 -0
  62. /package/dist/{src/transports → transports}/nats/index.js +0 -0
  63. /package/dist/{src/transports → transports}/nats/jetstream-message-processor.d.ts +0 -0
  64. /package/dist/{src/transports → transports}/nats/jetstream-message-processor.js +0 -0
  65. /package/dist/{src/transports → transports}/nats/nats-consumer.d.ts +0 -0
  66. /package/dist/{src/transports → transports}/nats/nats-consumer.js +0 -0
  67. /package/dist/{src/transports → transports}/nats/nats-message-processor.d.ts +0 -0
  68. /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/handlers/order-created.event.ts`):
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
- const OrderCreatedSchema = z.object({
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. Start consuming:**
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', // Auto-created if not exists
64
- subjects: ['orders.*'],
75
+ stream: 'ORDERS',
65
76
  consumer: 'my-service',
66
- discover: './src/handlers/**/*.event.ts',
77
+ discover: './src/events/**/*.event.ts',
67
78
  })
68
79
  ```
69
80
 
70
- **3. Publish from another service:**
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/handlers/user-signup.event.ts
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/handlers/**/*.event.ts',
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/handlers/**/*.event.ts',
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/handlers/**/*.event.ts',
233
+ discover: './src/events/**/*.event.ts',
188
234
 
189
235
  // Optional
190
236
  servers: 'nats://localhost:4222',
191
- maxDeliver: 5, // Retry attempts
192
- ackWait: 30_000, // Timeout per attempt (ms)
193
- quarantineTopic: 'dlq', // For poison messages
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/handlers/**/*.event.ts' }))
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 { dirname } from 'node:path';
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
- return [...directories];
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
- const prefixedPattern = `{src,dist,build,lib,out}/${pattern}`;
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
- const files = await glob(variant, { cwd: basePath, absolute: true });
90
- allFiles.push(...files);
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
- * @example Data-only schema
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>, handler: (payload: TSchema['_output'], context?: EventContext) => Promise<void> | void, eventType?: string): HandlerConstructor;
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
- * @example Data-only schema
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';
@@ -1,3 +1,4 @@
1
1
  export { discoverHandlers } from './discovery';
2
+ export { createContract } from './contract-helper';
2
3
  export { eventSchema, handleEvent } from './handler-factory';
3
4
  export { extractTypeFromSchema, isValidHandler } 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 extends ZodTypeAny> {
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 type { EnrichedEvent, IdempotencyStore, InferEventData, RoutingConfig } from './domain';
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 consumer configuration
16
+ * Options for creating/ensuring a JetStream stream exists
17
17
  */
18
- export interface JetStreamConsumerOptions extends Pick<CloudEventsOptions, 'quarantineTopic' | 'errorTopic' | 'projectId' | 'source'> {
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. Will be auto-created if it doesn't exist */
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 exists
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
- // 6) Get consumer and start consuming
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
- // Process messages
210
- const processMessages = async () => {
211
- for await (const msg of messages) {
212
- if (abortController.signal.aborted)
213
- break;
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.4",
3
+ "version": "0.4.1",
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/src/index.js",
18
- "types": "dist/src/index.d.ts",
17
+ "main": "dist/index.js",
18
+ "types": "dist/index.d.ts",
19
19
  "files": [
20
- "dist/src"
20
+ "dist"
21
21
  ],
22
22
  "exports": {
23
23
  ".": {
24
- "import": "./dist/src/index.js",
25
- "types": "./dist/src/index.d.ts"
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