opinionated-machine 6.3.0 → 6.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 CHANGED
@@ -398,7 +398,7 @@ await app.register(FastifySSEPlugin)
398
398
 
399
399
  ### Defining SSE Contracts
400
400
 
401
- Use `buildContract` to define SSE routes. The contract type is automatically determined based on the presence of `body` and `syncResponse` fields. Paths are defined using `pathResolver`, a type-safe function that receives typed params and returns the URL path:
401
+ Use `buildContract` to define SSE routes. The contract type is automatically determined based on the presence of `requestBody` and `jsonResponse` fields. Paths are defined using `pathResolver`, a type-safe function that receives typed params and returns the URL path:
402
402
 
403
403
  ```ts
404
404
  import { z } from 'zod'
@@ -429,14 +429,14 @@ export const notificationsContract = buildContract({
429
429
  },
430
430
  })
431
431
 
432
- // POST-based SSE stream (e.g., AI chat completions) - has body = POST/PUT/PATCH
432
+ // POST-based SSE stream (e.g., AI chat completions) - has requestBody = POST/PUT/PATCH
433
433
  export const chatCompletionContract = buildContract({
434
434
  method: 'POST',
435
435
  pathResolver: () => '/api/chat/completions',
436
436
  params: z.object({}),
437
437
  query: z.object({}),
438
438
  requestHeaders: z.object({}),
439
- body: z.object({
439
+ requestBody: z.object({
440
440
  message: z.string(),
441
441
  stream: z.literal(true),
442
442
  }),
@@ -502,7 +502,7 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
502
502
  handlers: this.handleStream,
503
503
  options: {
504
504
  onConnect: (conn) => this.onConnect(conn),
505
- onDisconnect: (conn) => this.onDisconnect(conn),
505
+ onClose: (conn, reason) => this.onClose(conn, reason),
506
506
  },
507
507
  },
508
508
  }
@@ -541,10 +541,10 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
541
541
  console.log('Client connected:', connection.id)
542
542
  }
543
543
 
544
- private onDisconnect = (connection: SSEConnection) => {
544
+ private onClose = (connection: SSEConnection, reason: SSECloseReason) => {
545
545
  const userId = connection.context?.userId as string
546
546
  this.notificationService.unsubscribe(userId)
547
- console.log('Client disconnected:', connection.id)
547
+ console.log(`Client disconnected (${reason}):`, connection.id)
548
548
  }
549
549
  }
550
550
  ```
@@ -736,7 +736,7 @@ public buildSSERoutes() {
736
736
  }
737
737
  },
738
738
  onConnect: (conn) => console.log('Admin connected'),
739
- onDisconnect: (conn) => console.log('Admin disconnected'),
739
+ onClose: (conn, reason) => console.log(`Admin disconnected (${reason})`),
740
740
  // Handle client reconnection with Last-Event-ID
741
741
  onReconnect: async (conn, lastEventId) => {
742
742
  // Return events to replay, or handle manually
@@ -753,12 +753,64 @@ public buildSSERoutes() {
753
753
  **Available route options:**
754
754
 
755
755
  | Option | Description |
756
- |--------|-------------|
756
+ | -------- | ------------- |
757
757
  | `preHandler` | Authentication/authorization hook that runs before SSE connection |
758
758
  | `onConnect` | Called after client connects (SSE handshake complete) |
759
- | `onDisconnect` | Called when client disconnects |
759
+ | `onClose` | Called when connection closes (client disconnect, network failure, or server close). Receives `(connection, reason)` where reason is `'server'` or `'client'` |
760
760
  | `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
761
761
  | `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
762
+ | `serializer` | Custom serializer for SSE data (e.g., for custom JSON encoding) |
763
+ | `heartbeatInterval` | Interval in ms for heartbeat keep-alive messages |
764
+
765
+ **onClose reason parameter:**
766
+ - `'server'`: Server explicitly closed the connection (via `closeConnection()` or `success('disconnect')`)
767
+ - `'client'`: Client closed the connection (EventSource.close(), navigation, network failure)
768
+
769
+ ```ts
770
+ options: {
771
+ onConnect: (conn) => console.log('Client connected'),
772
+ onClose: (conn, reason) => {
773
+ console.log(`Connection closed (${reason}):`, conn.id)
774
+ // reason is 'server' or 'client'
775
+ },
776
+ serializer: (data) => JSON.stringify(data, null, 2), // Pretty-print JSON
777
+ heartbeatInterval: 30000, // Send heartbeat every 30 seconds
778
+ }
779
+ ```
780
+
781
+ ### SSE Connection Methods
782
+
783
+ The `connection` object in SSE handlers provides several useful methods:
784
+
785
+ ```ts
786
+ private handleStream = buildHandler(streamContract, {
787
+ sse: async (request, connection) => {
788
+ // Check if connection is still active
789
+ if (connection.isConnected()) {
790
+ await connection.send('status', { connected: true })
791
+ }
792
+
793
+ // Get raw writable stream for advanced use cases (e.g., pipeline)
794
+ const stream = connection.getStream()
795
+
796
+ // Stream messages from an async iterable with automatic validation
797
+ async function* generateMessages() {
798
+ yield { event: 'message' as const, data: { text: 'Hello' } }
799
+ yield { event: 'message' as const, data: { text: 'World' } }
800
+ }
801
+ await connection.sendStream(generateMessages())
802
+
803
+ return success('disconnect')
804
+ },
805
+ })
806
+ ```
807
+
808
+ | Method | Description |
809
+ | -------- | ------------- |
810
+ | `send(event, data, options?)` | Send a typed event (validates against contract schema) |
811
+ | `isConnected()` | Check if the connection is still active |
812
+ | `getStream()` | Get the underlying `WritableStream` for advanced use cases |
813
+ | `sendStream(messages)` | Stream messages from an `AsyncIterable` with validation |
762
814
 
763
815
  ### Graceful Shutdown
764
816
 
@@ -779,7 +831,7 @@ if (!sent) {
779
831
  }
780
832
  ```
781
833
 
782
- **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onDisconnect`):
834
+ **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
783
835
  - All lifecycle hooks are wrapped in try/catch to prevent crashes
784
836
  - If a `logger` is provided in route options, errors are logged with context
785
837
  - If no logger is provided, errors are silently ignored
@@ -795,7 +847,7 @@ public buildSSERoutes() {
795
847
  options: {
796
848
  logger: this.logger, // pino-compatible logger
797
849
  onConnect: (conn) => { /* may throw */ },
798
- onDisconnect: (conn) => { /* may throw */ },
850
+ onClose: (conn, reason) => { /* may throw */ },
799
851
  },
800
852
  },
801
853
  }
@@ -1142,7 +1194,7 @@ import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
1142
1194
  const server = await SSETestServer.create(async (app) => {
1143
1195
  app.get('/api/events', async (request, reply) => {
1144
1196
  reply.sse({ event: 'message', data: { hello: 'world' } })
1145
- reply.sseClose()
1197
+ reply.sse.close()
1146
1198
  })
1147
1199
  })
1148
1200
 
@@ -1360,21 +1412,21 @@ Dual-mode contracts define endpoints that can return **either** a complete JSON
1360
1412
  - You're building OpenAI-style APIs where `stream: true` triggers SSE
1361
1413
  - You need polling fallback for clients that don't support SSE
1362
1414
 
1363
- To create a dual-mode contract, include a `syncResponse` schema in your `buildContract` call:
1364
- - Has `syncResponse` but no `body` → GET dual-mode route
1365
- - Has both `syncResponse` and `body` → POST/PUT/PATCH dual-mode route
1415
+ To create a dual-mode contract, include a `jsonResponse` schema in your `buildContract` call:
1416
+ - Has `jsonResponse` but no `requestBody` → GET dual-mode route
1417
+ - Has both `jsonResponse` and `requestBody` → POST/PUT/PATCH dual-mode route
1366
1418
 
1367
1419
  ```ts
1368
1420
  import { z } from 'zod'
1369
1421
  import { buildContract } from 'opinionated-machine'
1370
1422
 
1371
- // GET dual-mode route (polling or streaming job status) - has syncResponse, no body
1423
+ // GET dual-mode route (polling or streaming job status) - has jsonResponse, no requestBody
1372
1424
  export const jobStatusContract = buildContract({
1373
1425
  pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
1374
1426
  params: z.object({ jobId: z.string().uuid() }),
1375
1427
  query: z.object({ verbose: z.string().optional() }),
1376
1428
  requestHeaders: z.object({}),
1377
- syncResponse: z.object({
1429
+ jsonResponse: z.object({
1378
1430
  status: z.enum(['pending', 'running', 'completed', 'failed']),
1379
1431
  progress: z.number(),
1380
1432
  result: z.string().optional(),
@@ -1385,15 +1437,15 @@ export const jobStatusContract = buildContract({
1385
1437
  },
1386
1438
  })
1387
1439
 
1388
- // POST dual-mode route (OpenAI-style chat completion) - has both syncResponse and body
1440
+ // POST dual-mode route (OpenAI-style chat completion) - has both jsonResponse and requestBody
1389
1441
  export const chatCompletionContract = buildContract({
1390
1442
  method: 'POST',
1391
1443
  pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
1392
1444
  params: z.object({ chatId: z.string().uuid() }),
1393
1445
  query: z.object({}),
1394
1446
  requestHeaders: z.object({ authorization: z.string() }),
1395
- body: z.object({ message: z.string() }),
1396
- syncResponse: z.object({
1447
+ requestBody: z.object({ message: z.string() }),
1448
+ jsonResponse: z.object({
1397
1449
  reply: z.string(),
1398
1450
  usage: z.object({ tokens: z.number() }),
1399
1451
  }),
@@ -1417,8 +1469,8 @@ export const rateLimitedContract = buildContract({
1417
1469
  params: z.object({}),
1418
1470
  query: z.object({}),
1419
1471
  requestHeaders: z.object({}),
1420
- body: z.object({ data: z.string() }),
1421
- syncResponse: z.object({ result: z.string() }),
1472
+ requestBody: z.object({ data: z.string() }),
1473
+ jsonResponse: z.object({ result: z.string() }),
1422
1474
  // Define expected response headers
1423
1475
  responseHeaders: z.object({
1424
1476
  'x-ratelimit-limit': z.string(),
@@ -1447,6 +1499,73 @@ handlers: {
1447
1499
 
1448
1500
  If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
1449
1501
 
1502
+ ### Multi-Format Responses (Verbose Mode)
1503
+
1504
+ For endpoints that need to return multiple response formats (JSON, plain text, CSV, etc.), use `multiFormatResponses` instead of `jsonResponse`. This enables content negotiation based on the `Accept` header:
1505
+
1506
+ ```ts
1507
+ import { z } from 'zod'
1508
+ import { buildContract } from 'opinionated-machine'
1509
+
1510
+ // Multi-format export endpoint
1511
+ export const exportContract = buildContract({
1512
+ method: 'POST',
1513
+ pathResolver: () => '/api/export',
1514
+ params: z.object({}),
1515
+ query: z.object({}),
1516
+ requestHeaders: z.object({}),
1517
+ requestBody: z.object({
1518
+ data: z.array(z.object({ name: z.string(), value: z.number() })),
1519
+ }),
1520
+ // Define multiple response formats
1521
+ multiFormatResponses: {
1522
+ 'application/json': z.object({
1523
+ items: z.array(z.object({ name: z.string(), value: z.number() })),
1524
+ count: z.number(),
1525
+ }),
1526
+ 'text/plain': z.string(),
1527
+ 'text/csv': z.string(),
1528
+ },
1529
+ events: {
1530
+ progress: z.object({ percent: z.number() }),
1531
+ done: z.object({ format: z.string() }),
1532
+ },
1533
+ })
1534
+ ```
1535
+
1536
+ The handler structure changes to `sync` with per-format handlers:
1537
+
1538
+ ```ts
1539
+ handlers: buildHandler(exportContract, {
1540
+ sync: {
1541
+ 'application/json': (request) => ({
1542
+ items: request.body.data,
1543
+ count: request.body.data.length,
1544
+ }),
1545
+ 'text/plain': (request) =>
1546
+ request.body.data.map((item) => `${item.name}: ${item.value}`).join('\n'),
1547
+ 'text/csv': (request) =>
1548
+ `name,value\n${request.body.data.map((item) => `${item.name},${item.value}`).join('\n')}`,
1549
+ },
1550
+ sse: async (request, connection) => {
1551
+ // SSE streaming handler
1552
+ await connection.send('done', { totalItems: request.body.data.length })
1553
+ return success('disconnect')
1554
+ },
1555
+ })
1556
+ ```
1557
+
1558
+ **Contract styles comparison:**
1559
+
1560
+ | Style | Contract Field | Handler Key | Use Case |
1561
+ |-------|---------------|-------------|----------|
1562
+ | Simplified | `jsonResponse` | `json` | Single JSON format (recommended) |
1563
+ | Verbose | `multiFormatResponses` | `sync` | Multiple formats (JSON, text, CSV, etc.) |
1564
+
1565
+ TypeScript enforces the correct handler structure based on your contract:
1566
+ - `jsonResponse` contracts must use `json` handler
1567
+ - `multiFormatResponses` contracts must use `sync` handlers for all declared formats
1568
+
1450
1569
  ### Implementing Dual-Mode Controllers
1451
1570
 
1452
1571
  Dual-mode controllers use `buildHandler` to define both JSON and SSE handlers:
@@ -1514,7 +1633,7 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
1514
1633
  },
1515
1634
  // Optional: SSE lifecycle hooks
1516
1635
  onConnect: (conn) => console.log('Client connected:', conn.id),
1517
- onDisconnect: (conn) => console.log('Client disconnected:', conn.id),
1636
+ onClose: (conn, reason) => console.log(`Client disconnected (${reason}):`, conn.id),
1518
1637
  },
1519
1638
  },
1520
1639
  }
@@ -1529,7 +1648,7 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
1529
1648
  | `json` | `(request, reply) => Response` |
1530
1649
  | `sse` | `(request, connection) => Either<Error, SSEHandlerResult>` |
1531
1650
 
1532
- The `json` handler must return a value matching `syncResponse` schema. The `sse` handler uses `connection.send()` for type-safe event streaming.
1651
+ The `json` handler must return a value matching `jsonResponse` schema. The `sse` handler uses `connection.send()` for type-safe event streaming.
1533
1652
 
1534
1653
  ### Registering Dual-Mode Controllers
1535
1654
 
@@ -1612,6 +1731,21 @@ curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
1612
1731
  curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
1613
1732
  ```
1614
1733
 
1734
+ **Subtype wildcards** are supported for flexible content negotiation:
1735
+
1736
+ ```bash
1737
+ # Accept any text format (matches text/plain, text/csv, etc.)
1738
+ curl -H "Accept: text/*" ...
1739
+
1740
+ # Accept any application format (matches application/json, application/xml, etc.)
1741
+ curl -H "Accept: application/*" ...
1742
+
1743
+ # Combine with quality values
1744
+ curl -H "Accept: text/event-stream;q=0.9, application/*;q=0.5" ...
1745
+ ```
1746
+
1747
+ The matching priority is: `text/event-stream` (SSE) > exact matches > subtype wildcards > `*/*` > fallback.
1748
+
1615
1749
  ### Testing Dual-Mode Controllers
1616
1750
 
1617
1751
  Test both JSON and SSE modes:
@@ -1,10 +1,10 @@
1
1
  import type { z } from 'zod';
2
- import type { DualModeContractDefinition, PathResolver } from '../dualmode/dualModeContracts.ts';
2
+ import type { MultiFormatResponses, PathResolver, SimplifiedDualModeContractDefinition, VerboseDualModeContractDefinition } from '../dualmode/dualModeContracts.ts';
3
3
  import type { SSEContractDefinition, SSEPathResolver } from '../sse/sseContracts.ts';
4
4
  import type { SSEEventSchemas } from '../sse/sseTypes.ts';
5
5
  /**
6
6
  * Configuration for building a GET SSE route.
7
- * Forbids body for GET variants.
7
+ * Forbids requestBody for GET variants.
8
8
  */
9
9
  export type SSEGetContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Events extends SSEEventSchemas> = {
10
10
  pathResolver: SSEPathResolver<z.infer<Params>>;
@@ -12,12 +12,13 @@ export type SSEGetContractConfig<Params extends z.ZodTypeAny, Query extends z.Zo
12
12
  query: Query;
13
13
  requestHeaders: RequestHeaders;
14
14
  events: Events;
15
- body?: never;
16
- syncResponse?: never;
15
+ requestBody?: never;
16
+ jsonResponse?: never;
17
+ multiFormatResponses?: never;
17
18
  };
18
19
  /**
19
- * Configuration for building a POST/PUT/PATCH SSE route with request body.
20
- * Requires body for payload variants.
20
+ * Configuration for building a POST/PUT/PATCH SSE route with request requestBody.
21
+ * Requires requestBody for payload variants.
21
22
  */
22
23
  export type SSEPayloadContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, Events extends SSEEventSchemas> = {
23
24
  method?: 'POST' | 'PUT' | 'PATCH';
@@ -25,20 +26,23 @@ export type SSEPayloadContractConfig<Params extends z.ZodTypeAny, Query extends
25
26
  params: Params;
26
27
  query: Query;
27
28
  requestHeaders: RequestHeaders;
28
- body: Body;
29
+ requestBody: Body;
29
30
  events: Events;
30
- syncResponse?: never;
31
+ jsonResponse?: never;
32
+ multiFormatResponses?: never;
31
33
  };
32
34
  /**
33
- * Configuration for building a GET dual-mode route.
34
- * Has syncResponse, forbids body.
35
+ * Configuration for building a GET dual-mode route (simplified - single JSON format).
36
+ * Requires jsonResponse, forbids requestBody.
35
37
  */
36
- export type DualModeGetContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, SyncResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
38
+ export type DualModeGetContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, JsonResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
37
39
  pathResolver: PathResolver<z.infer<Params>>;
38
40
  params: Params;
39
41
  query: Query;
40
42
  requestHeaders: RequestHeaders;
41
- syncResponse: SyncResponse;
43
+ /** Single JSON response schema */
44
+ jsonResponse: JsonResponse;
45
+ multiFormatResponses?: never;
42
46
  /**
43
47
  * Schema for validating response headers (JSON mode only).
44
48
  * Used to define and validate headers that the server will send in the response.
@@ -53,20 +57,22 @@ export type DualModeGetContractConfig<Params extends z.ZodTypeAny, Query extends
53
57
  */
54
58
  responseHeaders?: ResponseHeaders;
55
59
  events: Events;
56
- body?: never;
60
+ requestBody?: never;
57
61
  };
58
62
  /**
59
- * Configuration for building a POST/PUT/PATCH dual-mode route with request body.
60
- * Has both body and syncResponse.
63
+ * Configuration for building a POST/PUT/PATCH dual-mode route with request requestBody (simplified).
64
+ * Requires both requestBody and jsonResponse.
61
65
  */
62
- export type DualModePayloadContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, SyncResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
66
+ export type DualModePayloadContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, JsonResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
63
67
  method?: 'POST' | 'PUT' | 'PATCH';
64
68
  pathResolver: PathResolver<z.infer<Params>>;
65
69
  params: Params;
66
70
  query: Query;
67
71
  requestHeaders: RequestHeaders;
68
- body: Body;
69
- syncResponse: SyncResponse;
72
+ requestBody: Body;
73
+ /** Single JSON response schema */
74
+ jsonResponse: JsonResponse;
75
+ multiFormatResponses?: never;
70
76
  /**
71
77
  * Schema for validating response headers (JSON mode only).
72
78
  * Used to define and validate headers that the server will send in the response.
@@ -83,80 +89,41 @@ export type DualModePayloadContractConfig<Params extends z.ZodTypeAny, Query ext
83
89
  events: Events;
84
90
  };
85
91
  /**
86
- * Unified contract builder with 4 overloads.
87
- *
88
- * Automatically determines the contract type based on the presence of `body` and `syncResponse`:
89
- *
90
- * | syncResponse | body | Result |
91
- * |--------------|------|--------|
92
- * | ❌ absent | ❌ absent | SSE GET |
93
- * | ❌ absent | ✅ present | SSE POST/PUT/PATCH |
94
- * | ✅ present | ❌ absent | Dual-mode GET |
95
- * | ✅ present | ✅ present | Dual-mode POST/PUT/PATCH |
96
- *
97
- * @example
98
- * ```typescript
99
- * // SSE GET - no body, no syncResponse
100
- * const notificationsStream = buildContract({
101
- * pathResolver: () => '/api/notifications/stream',
102
- * params: z.object({}),
103
- * query: z.object({ userId: z.string().optional() }),
104
- * requestHeaders: z.object({}),
105
- * events: {
106
- * notification: z.object({ id: z.string(), message: z.string() }),
107
- * },
108
- * })
109
- *
110
- * // SSE POST - has body, no syncResponse
111
- * const chatCompletionStream = buildContract({
112
- * method: 'POST',
113
- * pathResolver: () => '/api/chat/completions',
114
- * params: z.object({}),
115
- * query: z.object({}),
116
- * requestHeaders: z.object({}),
117
- * body: z.object({ message: z.string(), stream: z.literal(true) }),
118
- * events: {
119
- * chunk: z.object({ content: z.string() }),
120
- * done: z.object({ totalTokens: z.number() }),
121
- * },
122
- * })
123
- *
124
- * // Dual-mode GET - has syncResponse, no body
125
- * const jobStatus = buildContract({
126
- * pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
127
- * params: z.object({ jobId: z.string().uuid() }),
128
- * query: z.object({}),
129
- * requestHeaders: z.object({}),
130
- * syncResponse: z.object({
131
- * status: z.enum(['pending', 'running', 'completed']),
132
- * progress: z.number(),
133
- * }),
134
- * events: {
135
- * progress: z.object({ percent: z.number() }),
136
- * done: z.object({ result: z.string() }),
137
- * },
138
- * })
139
- *
140
- * // Dual-mode POST - has both body and syncResponse
141
- * const chatCompletion = buildContract({
142
- * method: 'POST',
143
- * pathResolver: () => '/api/chat/completions',
144
- * params: z.object({}),
145
- * query: z.object({}),
146
- * requestHeaders: z.object({ authorization: z.string() }),
147
- * body: z.object({ message: z.string() }),
148
- * syncResponse: z.object({
149
- * reply: z.string(),
150
- * usage: z.object({ tokens: z.number() }),
151
- * }),
152
- * events: {
153
- * chunk: z.object({ delta: z.string() }),
154
- * done: z.object({ usage: z.object({ total: z.number() }) }),
155
- * },
156
- * })
157
- * ```
92
+ * Configuration for building a GET dual-mode route with multi-format responses.
93
+ * Has multiFormatResponses, forbids requestBody and jsonResponse.
158
94
  */
159
- export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, SyncResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: DualModePayloadContractConfig<Params, Query, RequestHeaders, Body, SyncResponse, Events, ResponseHeaders>): DualModeContractDefinition<'POST' | 'PUT' | 'PATCH', Params, Query, RequestHeaders, Body, SyncResponse, Events, ResponseHeaders>;
160
- export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, SyncResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: DualModeGetContractConfig<Params, Query, RequestHeaders, SyncResponse, Events, ResponseHeaders>): DualModeContractDefinition<'GET', Params, Query, RequestHeaders, undefined, SyncResponse, Events, ResponseHeaders>;
95
+ export type MultiFormatGetContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Formats extends MultiFormatResponses, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
96
+ pathResolver: PathResolver<z.infer<Params>>;
97
+ params: Params;
98
+ query: Query;
99
+ requestHeaders: RequestHeaders;
100
+ jsonResponse?: never;
101
+ /** Multi-format response schemas */
102
+ multiFormatResponses: Formats;
103
+ responseHeaders?: ResponseHeaders;
104
+ events: Events;
105
+ requestBody?: never;
106
+ };
107
+ /**
108
+ * Configuration for building a POST/PUT/PATCH dual-mode route with multi-format responses.
109
+ * Has both requestBody and multiFormatResponses.
110
+ */
111
+ export type MultiFormatPayloadContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, Formats extends MultiFormatResponses, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
112
+ method?: 'POST' | 'PUT' | 'PATCH';
113
+ pathResolver: PathResolver<z.infer<Params>>;
114
+ params: Params;
115
+ query: Query;
116
+ requestHeaders: RequestHeaders;
117
+ requestBody: Body;
118
+ jsonResponse?: never;
119
+ /** Multi-format response schemas */
120
+ multiFormatResponses: Formats;
121
+ responseHeaders?: ResponseHeaders;
122
+ events: Events;
123
+ };
124
+ export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, Formats extends MultiFormatResponses, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: MultiFormatPayloadContractConfig<Params, Query, RequestHeaders, Body, Formats, Events, ResponseHeaders>): VerboseDualModeContractDefinition<'POST' | 'PUT' | 'PATCH', Params, Query, RequestHeaders, Body, Formats, Events, ResponseHeaders>;
125
+ export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Formats extends MultiFormatResponses, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: MultiFormatGetContractConfig<Params, Query, RequestHeaders, Formats, Events, ResponseHeaders>): VerboseDualModeContractDefinition<'GET', Params, Query, RequestHeaders, undefined, Formats, Events, ResponseHeaders>;
126
+ export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, JsonResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: DualModePayloadContractConfig<Params, Query, RequestHeaders, Body, JsonResponse, Events, ResponseHeaders>): SimplifiedDualModeContractDefinition<'POST' | 'PUT' | 'PATCH', Params, Query, RequestHeaders, Body, JsonResponse, Events, ResponseHeaders>;
127
+ export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, JsonResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: DualModeGetContractConfig<Params, Query, RequestHeaders, JsonResponse, Events, ResponseHeaders>): SimplifiedDualModeContractDefinition<'GET', Params, Query, RequestHeaders, undefined, JsonResponse, Events, ResponseHeaders>;
161
128
  export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, Events extends SSEEventSchemas>(config: SSEPayloadContractConfig<Params, Query, RequestHeaders, Body, Events>): SSEContractDefinition<'POST' | 'PUT' | 'PATCH', Params, Query, RequestHeaders, Body, Events>;
162
129
  export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Events extends SSEEventSchemas>(config: SSEGetContractConfig<Params, Query, RequestHeaders, Events>): SSEContractDefinition<'GET', Params, Query, RequestHeaders, undefined, Events>;
@@ -1,29 +1,105 @@
1
+ /**
2
+ * Unified contract builder with overloads for SSE-only, simplified dual-mode, and verbose dual-mode contracts.
3
+ *
4
+ * Automatically determines the contract type based on the presence of `requestBody`, `jsonResponse`, and `multiFormatResponses`:
5
+ *
6
+ * | Response Config | requestBody | Result |
7
+ * |-----------------|------|--------|
8
+ * | none | ❌ | SSE GET |
9
+ * | none | ✅ | SSE POST/PUT/PATCH |
10
+ * | jsonResponse | ❌ | Simplified Dual-mode GET |
11
+ * | jsonResponse | ✅ | Simplified Dual-mode POST/PUT/PATCH |
12
+ * | multiFormatResponses | ❌ | Verbose Dual-mode GET |
13
+ * | multiFormatResponses | ✅ | Verbose Dual-mode POST/PUT/PATCH |
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // SSE GET - no requestBody, no jsonResponse/multiFormatResponses
18
+ * const notificationsStream = buildContract({
19
+ * pathResolver: () => '/api/notifications/stream',
20
+ * params: z.object({}),
21
+ * query: z.object({ userId: z.string().optional() }),
22
+ * requestHeaders: z.object({}),
23
+ * events: { notification: z.object({ id: z.string(), message: z.string() }) },
24
+ * })
25
+ *
26
+ * // Simplified dual-mode POST (recommended) - single JSON format
27
+ * const chatCompletion = buildContract({
28
+ * method: 'POST',
29
+ * pathResolver: () => '/api/chat/completions',
30
+ * params: z.object({}),
31
+ * query: z.object({}),
32
+ * requestHeaders: z.object({}),
33
+ * requestBody: z.object({ message: z.string() }),
34
+ * jsonResponse: z.object({ reply: z.string(), usage: z.object({ tokens: z.number() }) }),
35
+ * events: { chunk: z.object({ delta: z.string() }), done: z.object({ usage: z.object({ total: z.number() }) }) },
36
+ * })
37
+ *
38
+ * // Verbose dual-mode POST - multiple format support
39
+ * const exportData = buildContract({
40
+ * method: 'POST',
41
+ * pathResolver: () => '/api/export',
42
+ * params: z.object({}),
43
+ * query: z.object({}),
44
+ * requestHeaders: z.object({}),
45
+ * requestBody: z.object({ format: z.string() }),
46
+ * multiFormatResponses: {
47
+ * 'application/json': z.object({ data: z.array(z.unknown()) }),
48
+ * 'text/csv': z.string(),
49
+ * 'text/plain': z.string(),
50
+ * },
51
+ * events: { progress: z.object({ percent: z.number() }), done: z.object({ rowCount: z.number() }) },
52
+ * })
53
+ * ```
54
+ */
55
+ // Helper to build base contract fields
56
+ // biome-ignore lint/suspicious/noExplicitAny: Config union type
57
+ function buildBaseFields(config, hasBody) {
58
+ return {
59
+ pathResolver: config.pathResolver,
60
+ params: config.params,
61
+ query: config.query,
62
+ requestHeaders: config.requestHeaders,
63
+ requestBody: hasBody ? config.requestBody : undefined,
64
+ events: config.events,
65
+ };
66
+ }
67
+ // Helper to determine method
68
+ function determineMethod(config, hasBody, defaultMethod) {
69
+ return hasBody ? (config.method ?? defaultMethod) : 'GET';
70
+ }
1
71
  // Implementation
2
72
  export function buildContract(config) {
3
- const hasDualMode = 'syncResponse' in config && config.syncResponse !== undefined;
4
- const hasBody = 'body' in config && config.body !== undefined;
5
- if (hasDualMode) {
73
+ const hasMultiFormat = 'multiFormatResponses' in config && config.multiFormatResponses !== undefined;
74
+ const hasJsonResponse = 'jsonResponse' in config && config.jsonResponse !== undefined;
75
+ const hasBody = 'requestBody' in config && config.requestBody !== undefined;
76
+ const base = buildBaseFields(config, hasBody);
77
+ if (hasMultiFormat) {
78
+ // Verbose multi-format contract
6
79
  return {
7
- method: hasBody ? (config.method ?? 'POST') : 'GET',
8
- pathResolver: config.pathResolver,
9
- params: config.params,
10
- query: config.query,
11
- requestHeaders: config.requestHeaders,
12
- body: hasBody ? config.body : undefined,
13
- syncResponse: config.syncResponse,
80
+ ...base,
81
+ method: determineMethod(config, hasBody, 'POST'),
82
+ multiFormatResponses: config.multiFormatResponses,
14
83
  responseHeaders: config.responseHeaders,
15
- events: config.events,
16
84
  isDualMode: true,
85
+ isVerbose: true,
17
86
  };
18
87
  }
88
+ if (hasJsonResponse) {
89
+ // Simplified single-JSON-format contract
90
+ return {
91
+ ...base,
92
+ method: determineMethod(config, hasBody, 'POST'),
93
+ jsonResponse: config.jsonResponse,
94
+ responseHeaders: config.responseHeaders,
95
+ isDualMode: true,
96
+ isSimplified: true,
97
+ };
98
+ }
99
+ // SSE-only contract
19
100
  return {
20
- method: hasBody ? (config.method ?? 'POST') : 'GET',
21
- pathResolver: config.pathResolver,
22
- params: config.params,
23
- query: config.query,
24
- requestHeaders: config.requestHeaders,
25
- body: hasBody ? config.body : undefined,
26
- events: config.events,
101
+ ...base,
102
+ method: determineMethod(config, hasBody, 'POST'),
27
103
  isSSE: true,
28
104
  };
29
105
  }
@@ -1 +1 @@
1
- {"version":3,"file":"contractBuilders.js","sourceRoot":"","sources":["../../../lib/contracts/contractBuilders.ts"],"names":[],"mappings":"AAgRA,iBAAiB;AACjB,MAAM,UAAU,aAAa,CAC3B,MAO4C;IAG5C,MAAM,WAAW,GAAG,cAAc,IAAI,MAAM,IAAI,MAAM,CAAC,YAAY,KAAK,SAAS,CAAA;IACjF,MAAM,OAAO,GAAG,MAAM,IAAI,MAAM,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,CAAA;IAE7D,IAAI,WAAW,EAAE,CAAC;QAChB,OAAO;YACL,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAE,MAAsC,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK;YACpF,YAAY,EAAE,MAAM,CAAC,YAAY;YACjC,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,KAAK,EAAE,MAAM,CAAC,KAAK;YACnB,cAAc,EAAE,MAAM,CAAC,cAAc;YACrC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAE,MAA4B,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;YAC9D,YAAY,EAAG,MAAoC,CAAC,YAAY;YAChE,eAAe,EAAG,MAAwC,CAAC,eAAe;YAC1E,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,UAAU,EAAE,IAAI;SACjB,CAAA;IACH,CAAC;IAED,OAAO;QACL,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAE,MAAiC,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK;QAC/E,YAAY,EAAE,MAAM,CAAC,YAAY;QACjC,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,cAAc,EAAE,MAAM,CAAC,cAAc;QACrC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAE,MAA4B,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;QAC9D,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,KAAK,EAAE,IAAI;KACZ,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"contractBuilders.js","sourceRoot":"","sources":["../../../lib/contracts/contractBuilders.ts"],"names":[],"mappings":"AAgLA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAEH,uCAAuC;AACvC,gEAAgE;AAChE,SAAS,eAAe,CAAC,MAAW,EAAE,OAAgB;IACpD,OAAO;QACL,YAAY,EAAE,MAAM,CAAC,YAAY;QACjC,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,cAAc,EAAE,MAAM,CAAC,cAAc;QACrC,WAAW,EAAE,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS;QACrD,MAAM,EAAE,MAAM,CAAC,MAAM;KACtB,CAAA;AACH,CAAC;AAED,6BAA6B;AAC7B,SAAS,eAAe,CAAC,MAA2B,EAAE,OAAgB,EAAE,aAAqB;IAC3F,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,IAAI,aAAa,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;AAC3D,CAAC;AA2ID,iBAAiB;AACjB,MAAM,UAAU,aAAa,CAC3B,MAW4C;IAG5C,MAAM,cAAc,GAClB,sBAAsB,IAAI,MAAM,IAAI,MAAM,CAAC,oBAAoB,KAAK,SAAS,CAAA;IAC/E,MAAM,eAAe,GAAG,cAAc,IAAI,MAAM,IAAI,MAAM,CAAC,YAAY,KAAK,SAAS,CAAA;IACrF,MAAM,OAAO,GAAG,aAAa,IAAI,MAAM,IAAI,MAAM,CAAC,WAAW,KAAK,SAAS,CAAA;IAC3E,MAAM,IAAI,GAAG,eAAe,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAE7C,IAAI,cAAc,EAAE,CAAC;QACnB,gCAAgC;QAChC,OAAO;YACL,GAAG,IAAI;YACP,MAAM,EAAE,eAAe,CAAC,MAA6B,EAAE,OAAO,EAAE,MAAM,CAAC;YACvE,oBAAoB,EAAG,MAA4C,CAAC,oBAAoB;YACxF,eAAe,EAAG,MAAwC,CAAC,eAAe;YAC1E,UAAU,EAAE,IAAI;YAChB,SAAS,EAAE,IAAI;SAChB,CAAA;IACH,CAAC;IAED,IAAI,eAAe,EAAE,CAAC;QACpB,yCAAyC;QACzC,OAAO;YACL,GAAG,IAAI;YACP,MAAM,EAAE,eAAe,CAAC,MAA6B,EAAE,OAAO,EAAE,MAAM,CAAC;YACvE,YAAY,EAAG,MAAoC,CAAC,YAAY;YAChE,eAAe,EAAG,MAAwC,CAAC,eAAe;YAC1E,UAAU,EAAE,IAAI;YAChB,YAAY,EAAE,IAAI;SACnB,CAAA;IACH,CAAC;IAED,oBAAoB;IACpB,OAAO;QACL,GAAG,IAAI;QACP,MAAM,EAAE,eAAe,CAAC,MAA6B,EAAE,OAAO,EAAE,MAAM,CAAC;QACvE,KAAK,EAAE,IAAI;KACZ,CAAA;AACH,CAAC"}