opinionated-machine 6.2.1 → 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 +311 -82
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/DIContext.d.ts +1 -2
- package/dist/lib/DIContext.js +3 -4
- package/dist/lib/DIContext.js.map +1 -1
- package/dist/lib/contracts/contractBuilders.d.ts +129 -0
- package/dist/lib/contracts/contractBuilders.js +106 -0
- package/dist/lib/contracts/contractBuilders.js.map +1 -0
- package/dist/lib/contracts/index.d.ts +1 -0
- package/dist/lib/contracts/index.js +2 -0
- package/dist/lib/contracts/index.js.map +1 -0
- package/dist/lib/dualmode/AbstractDualModeController.d.ts +8 -9
- package/dist/lib/dualmode/AbstractDualModeController.js +7 -7
- package/dist/lib/dualmode/AbstractDualModeController.js.map +1 -1
- package/dist/lib/dualmode/dualModeContracts.d.ts +66 -77
- package/dist/lib/dualmode/dualModeContracts.js +6 -70
- package/dist/lib/dualmode/dualModeContracts.js.map +1 -1
- package/dist/lib/dualmode/index.d.ts +4 -3
- package/dist/lib/dualmode/index.js +5 -4
- package/dist/lib/dualmode/index.js.map +1 -1
- package/dist/lib/routes/fastifyRouteBuilder.d.ts +30 -0
- package/dist/lib/routes/fastifyRouteBuilder.js +331 -0
- package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -0
- package/dist/lib/routes/fastifyRouteTypes.d.ts +541 -0
- package/dist/lib/routes/fastifyRouteTypes.js +68 -0
- package/dist/lib/routes/fastifyRouteTypes.js.map +1 -0
- package/dist/lib/{sse/fastifySSERouteUtils.d.ts → routes/fastifyRouteUtils.d.ts} +61 -3
- package/dist/lib/routes/fastifyRouteUtils.js +315 -0
- package/dist/lib/routes/fastifyRouteUtils.js.map +1 -0
- package/dist/lib/routes/index.d.ts +4 -0
- package/dist/lib/routes/index.js +11 -0
- package/dist/lib/routes/index.js.map +1 -0
- package/dist/lib/sse/AbstractSSEController.d.ts +14 -9
- package/dist/lib/sse/AbstractSSEController.js +10 -5
- package/dist/lib/sse/AbstractSSEController.js.map +1 -1
- package/dist/lib/sse/index.d.ts +3 -3
- package/dist/lib/sse/index.js +4 -4
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/sse/sseContracts.d.ts +3 -80
- package/dist/lib/sse/sseContracts.js +1 -69
- package/dist/lib/sse/sseContracts.js.map +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +1 -1
- package/dist/lib/testing/sseTestTypes.d.ts +1 -1
- package/package.json +6 -6
- package/dist/lib/dualmode/fastifyDualModeRouteBuilder.d.ts +0 -30
- package/dist/lib/dualmode/fastifyDualModeRouteBuilder.js +0 -137
- package/dist/lib/dualmode/fastifyDualModeRouteBuilder.js.map +0 -1
- package/dist/lib/dualmode/fastifyDualModeTypes.d.ts +0 -153
- package/dist/lib/dualmode/fastifyDualModeTypes.js +0 -25
- package/dist/lib/dualmode/fastifyDualModeTypes.js.map +0 -1
- package/dist/lib/sse/fastifySSERouteBuilder.d.ts +0 -50
- package/dist/lib/sse/fastifySSERouteBuilder.js +0 -53
- package/dist/lib/sse/fastifySSERouteBuilder.js.map +0 -1
- package/dist/lib/sse/fastifySSERouteUtils.js +0 -163
- package/dist/lib/sse/fastifySSERouteUtils.js.map +0 -1
- package/dist/lib/sse/fastifySSETypes.d.ts +0 -200
- package/dist/lib/sse/fastifySSETypes.js +0 -47
- package/dist/lib/sse/fastifySSETypes.js.map +0 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
30
30
|
- [Prerequisites](#prerequisites)
|
|
31
31
|
- [Defining SSE Contracts](#defining-sse-contracts)
|
|
32
32
|
- [Creating SSE Controllers](#creating-sse-controllers)
|
|
33
|
-
- [Type-Safe SSE Handlers with
|
|
33
|
+
- [Type-Safe SSE Handlers with buildHandler](#type-safe-sse-handlers-with-buildhandler)
|
|
34
34
|
- [SSE Controllers Without Dependencies](#sse-controllers-without-dependencies)
|
|
35
35
|
- [Registering SSE Controllers](#registering-sse-controllers)
|
|
36
36
|
- [Registering SSE Routes](#registering-sse-routes)
|
|
@@ -398,14 +398,14 @@ await app.register(FastifySSEPlugin)
|
|
|
398
398
|
|
|
399
399
|
### Defining SSE Contracts
|
|
400
400
|
|
|
401
|
-
Use `
|
|
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'
|
|
405
|
-
import {
|
|
405
|
+
import { buildContract } from 'opinionated-machine'
|
|
406
406
|
|
|
407
|
-
// GET-based SSE stream with path params
|
|
408
|
-
export const channelStreamContract =
|
|
407
|
+
// GET-based SSE stream with path params (no body = GET)
|
|
408
|
+
export const channelStreamContract = buildContract({
|
|
409
409
|
pathResolver: (params) => `/api/channels/${params.channelId}/stream`,
|
|
410
410
|
params: z.object({ channelId: z.string() }),
|
|
411
411
|
query: z.object({}),
|
|
@@ -416,7 +416,7 @@ export const channelStreamContract = buildSSEContract({
|
|
|
416
416
|
})
|
|
417
417
|
|
|
418
418
|
// GET-based SSE stream without path params
|
|
419
|
-
export const notificationsContract =
|
|
419
|
+
export const notificationsContract = buildContract({
|
|
420
420
|
pathResolver: () => '/api/notifications/stream',
|
|
421
421
|
params: z.object({}),
|
|
422
422
|
query: z.object({ userId: z.string().optional() }),
|
|
@@ -429,14 +429,14 @@ export const notificationsContract = buildSSEContract({
|
|
|
429
429
|
},
|
|
430
430
|
})
|
|
431
431
|
|
|
432
|
-
// POST-based SSE stream (e.g., AI chat completions)
|
|
433
|
-
export const chatCompletionContract =
|
|
432
|
+
// POST-based SSE stream (e.g., AI chat completions) - has requestBody = POST/PUT/PATCH
|
|
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
|
-
|
|
439
|
+
requestBody: z.object({
|
|
440
440
|
message: z.string(),
|
|
441
441
|
stream: z.literal(true),
|
|
442
442
|
}),
|
|
@@ -463,12 +463,13 @@ const streamingEvents = {
|
|
|
463
463
|
|
|
464
464
|
### Creating SSE Controllers
|
|
465
465
|
|
|
466
|
-
SSE controllers extend `AbstractSSEController` and must implement a two-parameter constructor. Use `
|
|
466
|
+
SSE controllers extend `AbstractSSEController` and must implement a two-parameter constructor. Use `buildHandler` for automatic type inference of request parameters:
|
|
467
467
|
|
|
468
468
|
```ts
|
|
469
469
|
import {
|
|
470
470
|
AbstractSSEController,
|
|
471
|
-
|
|
471
|
+
buildHandler,
|
|
472
|
+
success,
|
|
472
473
|
type SSEControllerConfig,
|
|
473
474
|
type SSEConnection
|
|
474
475
|
} from 'opinionated-machine'
|
|
@@ -498,10 +499,10 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
|
|
|
498
499
|
return {
|
|
499
500
|
notificationsStream: {
|
|
500
501
|
contract: NotificationsSSEController.contracts.notificationsStream,
|
|
501
|
-
|
|
502
|
+
handlers: this.handleStream,
|
|
502
503
|
options: {
|
|
503
504
|
onConnect: (conn) => this.onConnect(conn),
|
|
504
|
-
|
|
505
|
+
onClose: (conn, reason) => this.onClose(conn, reason),
|
|
505
506
|
},
|
|
506
507
|
},
|
|
507
508
|
}
|
|
@@ -509,9 +510,8 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
|
|
|
509
510
|
|
|
510
511
|
// Handler with automatic type inference from contract
|
|
511
512
|
// connection.send provides type-safe event sending
|
|
512
|
-
private handleStream =
|
|
513
|
-
|
|
514
|
-
async (request, connection) => {
|
|
513
|
+
private handleStream = buildHandler(notificationsContract, {
|
|
514
|
+
sse: async (request, connection) => {
|
|
515
515
|
// request.query is typed from contract: { userId?: string }
|
|
516
516
|
const userId = request.query.userId ?? 'anonymous'
|
|
517
517
|
connection.context = { userId }
|
|
@@ -531,29 +531,32 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
|
|
|
531
531
|
// For direct sending within the handler, use the connection's send method.
|
|
532
532
|
// It provides stricter per-route typing (only events from this specific contract).
|
|
533
533
|
await connection.send('notification', { id: 'welcome', message: 'Connected!' })
|
|
534
|
+
|
|
535
|
+
// Keep connection open for subscription events (client closes when done)
|
|
536
|
+
return success('maintain_connection')
|
|
534
537
|
},
|
|
535
|
-
)
|
|
538
|
+
})
|
|
536
539
|
|
|
537
540
|
private onConnect = (connection: SSEConnection) => {
|
|
538
541
|
console.log('Client connected:', connection.id)
|
|
539
542
|
}
|
|
540
543
|
|
|
541
|
-
private
|
|
544
|
+
private onClose = (connection: SSEConnection, reason: SSECloseReason) => {
|
|
542
545
|
const userId = connection.context?.userId as string
|
|
543
546
|
this.notificationService.unsubscribe(userId)
|
|
544
|
-
console.log(
|
|
547
|
+
console.log(`Client disconnected (${reason}):`, connection.id)
|
|
545
548
|
}
|
|
546
549
|
}
|
|
547
550
|
```
|
|
548
551
|
|
|
549
|
-
### Type-Safe SSE Handlers with `
|
|
552
|
+
### Type-Safe SSE Handlers with `buildHandler`
|
|
550
553
|
|
|
551
|
-
For automatic type inference of request parameters (similar to `buildFastifyPayloadRoute` for regular controllers), use `
|
|
554
|
+
For automatic type inference of request parameters (similar to `buildFastifyPayloadRoute` for regular controllers), use `buildHandler`:
|
|
552
555
|
|
|
553
556
|
```ts
|
|
554
557
|
import {
|
|
555
558
|
AbstractSSEController,
|
|
556
|
-
|
|
559
|
+
buildHandler,
|
|
557
560
|
type SSEControllerConfig,
|
|
558
561
|
type SSEConnection
|
|
559
562
|
} from 'opinionated-machine'
|
|
@@ -569,9 +572,8 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
|
|
|
569
572
|
|
|
570
573
|
// Handler with automatic type inference from contract
|
|
571
574
|
// connection.send is fully typed per-route
|
|
572
|
-
private handleChatCompletion =
|
|
573
|
-
|
|
574
|
-
async (request, connection) => {
|
|
575
|
+
private handleChatCompletion = buildHandler(chatCompletionContract, {
|
|
576
|
+
sse: async (request, connection) => {
|
|
575
577
|
// request.body is typed as { message: string; stream: true }
|
|
576
578
|
// request.query, request.params, request.headers all typed from contract
|
|
577
579
|
const words = request.body.message.split(' ')
|
|
@@ -582,15 +584,15 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
|
|
|
582
584
|
}
|
|
583
585
|
|
|
584
586
|
// Gracefully end the stream - all sent data is flushed before connection closes
|
|
585
|
-
|
|
587
|
+
return success('disconnect')
|
|
586
588
|
},
|
|
587
|
-
)
|
|
589
|
+
})
|
|
588
590
|
|
|
589
591
|
public buildSSERoutes() {
|
|
590
592
|
return {
|
|
591
593
|
chatCompletion: {
|
|
592
594
|
contract: ChatSSEController.contracts.chatCompletion,
|
|
593
|
-
|
|
595
|
+
handlers: this.handleChatCompletion,
|
|
594
596
|
},
|
|
595
597
|
}
|
|
596
598
|
}
|
|
@@ -725,7 +727,7 @@ public buildSSERoutes() {
|
|
|
725
727
|
return {
|
|
726
728
|
adminStream: {
|
|
727
729
|
contract: AdminSSEController.contracts.adminStream,
|
|
728
|
-
|
|
730
|
+
handlers: this.handleAdminStream,
|
|
729
731
|
options: {
|
|
730
732
|
// Route-specific authentication
|
|
731
733
|
preHandler: (request, reply) => {
|
|
@@ -734,7 +736,7 @@ public buildSSERoutes() {
|
|
|
734
736
|
}
|
|
735
737
|
},
|
|
736
738
|
onConnect: (conn) => console.log('Admin connected'),
|
|
737
|
-
|
|
739
|
+
onClose: (conn, reason) => console.log(`Admin disconnected (${reason})`),
|
|
738
740
|
// Handle client reconnection with Last-Event-ID
|
|
739
741
|
onReconnect: async (conn, lastEventId) => {
|
|
740
742
|
// Return events to replay, or handle manually
|
|
@@ -751,12 +753,64 @@ public buildSSERoutes() {
|
|
|
751
753
|
**Available route options:**
|
|
752
754
|
|
|
753
755
|
| Option | Description |
|
|
754
|
-
|
|
756
|
+
| -------- | ------------- |
|
|
755
757
|
| `preHandler` | Authentication/authorization hook that runs before SSE connection |
|
|
756
758
|
| `onConnect` | Called after client connects (SSE handshake complete) |
|
|
757
|
-
| `
|
|
759
|
+
| `onClose` | Called when connection closes (client disconnect, network failure, or server close). Receives `(connection, reason)` where reason is `'server'` or `'client'` |
|
|
758
760
|
| `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
|
|
759
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 |
|
|
760
814
|
|
|
761
815
|
### Graceful Shutdown
|
|
762
816
|
|
|
@@ -777,7 +831,7 @@ if (!sent) {
|
|
|
777
831
|
}
|
|
778
832
|
```
|
|
779
833
|
|
|
780
|
-
**Lifecycle hook errors** (`onConnect`, `onReconnect`, `
|
|
834
|
+
**Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
|
|
781
835
|
- All lifecycle hooks are wrapped in try/catch to prevent crashes
|
|
782
836
|
- If a `logger` is provided in route options, errors are logged with context
|
|
783
837
|
- If no logger is provided, errors are silently ignored
|
|
@@ -789,11 +843,11 @@ public buildSSERoutes() {
|
|
|
789
843
|
return {
|
|
790
844
|
stream: {
|
|
791
845
|
contract: streamContract,
|
|
792
|
-
|
|
846
|
+
handlers: this.handleStream,
|
|
793
847
|
options: {
|
|
794
848
|
logger: this.logger, // pino-compatible logger
|
|
795
849
|
onConnect: (conn) => { /* may throw */ },
|
|
796
|
-
|
|
850
|
+
onClose: (conn, reason) => { /* may throw */ },
|
|
797
851
|
},
|
|
798
852
|
},
|
|
799
853
|
}
|
|
@@ -802,43 +856,87 @@ public buildSSERoutes() {
|
|
|
802
856
|
|
|
803
857
|
### Long-lived Connections vs Request-Response Streaming
|
|
804
858
|
|
|
859
|
+
SSE handlers must return an `Either<Error, SSEHandlerResult>` to explicitly indicate connection management.
|
|
860
|
+
|
|
861
|
+
The `Either` type along with the `success` and `failure` helper functions are re-exported from `@lokalise/node-core` for convenience, so you don't need to add `@lokalise/node-core` as a direct dependency:
|
|
862
|
+
|
|
863
|
+
```ts
|
|
864
|
+
import { type Either, success, failure } from 'opinionated-machine'
|
|
865
|
+
|
|
866
|
+
// Return success('disconnect') - close connection after handler completes
|
|
867
|
+
// Return success('maintain_connection') - keep connection open for external events
|
|
868
|
+
// Return failure(error) - handle error and close connection
|
|
869
|
+
```
|
|
870
|
+
|
|
805
871
|
**Long-lived connections** (notifications, live updates):
|
|
806
|
-
- Handler sets up subscriptions and returns
|
|
807
|
-
- Connection stays open
|
|
808
|
-
-
|
|
872
|
+
- Handler sets up subscriptions and returns `success('maintain_connection')`
|
|
873
|
+
- Connection stays open indefinitely after handler returns
|
|
874
|
+
- Events are sent later via callbacks using `sendEventInternal()`
|
|
875
|
+
- **Client closes connection** when done (e.g., `eventSource.close()` or navigating away)
|
|
876
|
+
- Server cleans up via `onConnectionClosed()` hook
|
|
809
877
|
|
|
810
878
|
```ts
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
879
|
+
import { success } from 'opinionated-machine'
|
|
880
|
+
|
|
881
|
+
private handleStream = buildHandler(streamContract, {
|
|
882
|
+
sse: async (request, connection) => {
|
|
883
|
+
// Set up subscription - events sent via callback AFTER handler returns
|
|
884
|
+
this.service.subscribe(connection.id, (data) => {
|
|
885
|
+
this.sendEventInternal(connection.id, { event: 'update', data })
|
|
886
|
+
})
|
|
887
|
+
// Keep connection open until CLIENT disconnects
|
|
888
|
+
return success('maintain_connection')
|
|
889
|
+
},
|
|
818
890
|
})
|
|
891
|
+
|
|
892
|
+
// Clean up when client disconnects
|
|
893
|
+
protected onConnectionClosed(connection: SSEConnection): void {
|
|
894
|
+
this.service.unsubscribe(connection.id)
|
|
895
|
+
}
|
|
819
896
|
```
|
|
820
897
|
|
|
821
898
|
**Request-response streaming** (AI completions):
|
|
822
|
-
- Handler sends all events
|
|
823
|
-
- Use `connection.send` for type-safe event sending
|
|
899
|
+
- Handler sends all events synchronously, then returns `success('disconnect')`
|
|
900
|
+
- Use `connection.send()` for type-safe event sending within the handler
|
|
901
|
+
- Connection automatically closes when handler returns with `'disconnect'`
|
|
824
902
|
|
|
825
903
|
```ts
|
|
826
|
-
|
|
827
|
-
// request.body is typed from contract
|
|
828
|
-
const words = request.body.message.split(' ')
|
|
904
|
+
import { success } from 'opinionated-machine'
|
|
829
905
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
906
|
+
private handleChatCompletion = buildHandler(chatCompletionContract, {
|
|
907
|
+
sse: async (request, connection) => {
|
|
908
|
+
const words = request.body.message.split(' ')
|
|
834
909
|
|
|
835
|
-
|
|
910
|
+
for (const word of words) {
|
|
911
|
+
await connection.send('chunk', { content: word })
|
|
912
|
+
}
|
|
913
|
+
await connection.send('done', { totalTokens: words.length })
|
|
836
914
|
|
|
837
|
-
|
|
838
|
-
|
|
915
|
+
// Signal that streaming is complete and connection should close
|
|
916
|
+
return success('disconnect')
|
|
917
|
+
},
|
|
839
918
|
})
|
|
840
919
|
```
|
|
841
920
|
|
|
921
|
+
**Error handling:**
|
|
922
|
+
|
|
923
|
+
Return `failure(error)` to signal an error occurred. The framework will send an error event and close the connection:
|
|
924
|
+
|
|
925
|
+
```ts
|
|
926
|
+
import { success, failure } from 'opinionated-machine'
|
|
927
|
+
|
|
928
|
+
private handleStream = buildHandler(streamContract, {
|
|
929
|
+
sse: async (request, connection) => {
|
|
930
|
+
try {
|
|
931
|
+
const data = await this.service.getData(request.params.id)
|
|
932
|
+
await connection.send('data', data)
|
|
933
|
+
return success('disconnect')
|
|
934
|
+
} catch (err) {
|
|
935
|
+
return failure(err instanceof Error ? err : new Error(String(err)))
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
})
|
|
939
|
+
|
|
842
940
|
### SSE Parsing Utilities
|
|
843
941
|
|
|
844
942
|
The library provides production-ready utilities for parsing SSE (Server-Sent Events) streams:
|
|
@@ -1096,7 +1194,7 @@ import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
|
|
|
1096
1194
|
const server = await SSETestServer.create(async (app) => {
|
|
1097
1195
|
app.get('/api/events', async (request, reply) => {
|
|
1098
1196
|
reply.sse({ event: 'message', data: { hello: 'world' } })
|
|
1099
|
-
reply.
|
|
1197
|
+
reply.sse.close()
|
|
1100
1198
|
})
|
|
1101
1199
|
})
|
|
1102
1200
|
|
|
@@ -1308,14 +1406,22 @@ Dual-mode controllers extend `AbstractDualModeController` which inherits from `A
|
|
|
1308
1406
|
|
|
1309
1407
|
### Defining Dual-Mode Contracts
|
|
1310
1408
|
|
|
1311
|
-
|
|
1409
|
+
Dual-mode contracts define endpoints that can return **either** a complete JSON response **or** stream SSE events, based on the client's `Accept` header. Use dual-mode when:
|
|
1410
|
+
|
|
1411
|
+
- Clients may want immediate results (JSON) or real-time updates (SSE)
|
|
1412
|
+
- You're building OpenAI-style APIs where `stream: true` triggers SSE
|
|
1413
|
+
- You need polling fallback for clients that don't support SSE
|
|
1414
|
+
|
|
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
|
|
1312
1418
|
|
|
1313
1419
|
```ts
|
|
1314
1420
|
import { z } from 'zod'
|
|
1315
|
-
import {
|
|
1421
|
+
import { buildContract } from 'opinionated-machine'
|
|
1316
1422
|
|
|
1317
|
-
// GET dual-mode route (polling or streaming job status)
|
|
1318
|
-
export const jobStatusContract =
|
|
1423
|
+
// GET dual-mode route (polling or streaming job status) - has jsonResponse, no requestBody
|
|
1424
|
+
export const jobStatusContract = buildContract({
|
|
1319
1425
|
pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
|
|
1320
1426
|
params: z.object({ jobId: z.string().uuid() }),
|
|
1321
1427
|
query: z.object({ verbose: z.string().optional() }),
|
|
@@ -1331,14 +1437,14 @@ export const jobStatusContract = buildDualModeContract({
|
|
|
1331
1437
|
},
|
|
1332
1438
|
})
|
|
1333
1439
|
|
|
1334
|
-
// POST dual-mode route (OpenAI-style chat completion)
|
|
1335
|
-
export const chatCompletionContract =
|
|
1440
|
+
// POST dual-mode route (OpenAI-style chat completion) - has both jsonResponse and requestBody
|
|
1441
|
+
export const chatCompletionContract = buildContract({
|
|
1336
1442
|
method: 'POST',
|
|
1337
1443
|
pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
|
|
1338
1444
|
params: z.object({ chatId: z.string().uuid() }),
|
|
1339
1445
|
query: z.object({}),
|
|
1340
1446
|
requestHeaders: z.object({ authorization: z.string() }),
|
|
1341
|
-
|
|
1447
|
+
requestBody: z.object({ message: z.string() }),
|
|
1342
1448
|
jsonResponse: z.object({
|
|
1343
1449
|
reply: z.string(),
|
|
1344
1450
|
usage: z.object({ tokens: z.number() }),
|
|
@@ -1352,14 +1458,122 @@ export const chatCompletionContract = buildPayloadDualModeContract({
|
|
|
1352
1458
|
|
|
1353
1459
|
**Note**: Dual-mode contracts use `pathResolver` instead of static `path` for type-safe path construction. The `pathResolver` function receives typed params and returns the URL path.
|
|
1354
1460
|
|
|
1461
|
+
### Response Headers (JSON Mode)
|
|
1462
|
+
|
|
1463
|
+
Dual-mode contracts support an optional `responseHeaders` schema to define and validate headers sent with JSON responses. This is useful for documenting expected headers (rate limits, pagination, cache control) and validating that your handlers set them correctly:
|
|
1464
|
+
|
|
1465
|
+
```ts
|
|
1466
|
+
export const rateLimitedContract = buildContract({
|
|
1467
|
+
method: 'POST',
|
|
1468
|
+
pathResolver: () => '/api/rate-limited',
|
|
1469
|
+
params: z.object({}),
|
|
1470
|
+
query: z.object({}),
|
|
1471
|
+
requestHeaders: z.object({}),
|
|
1472
|
+
requestBody: z.object({ data: z.string() }),
|
|
1473
|
+
jsonResponse: z.object({ result: z.string() }),
|
|
1474
|
+
// Define expected response headers
|
|
1475
|
+
responseHeaders: z.object({
|
|
1476
|
+
'x-ratelimit-limit': z.string(),
|
|
1477
|
+
'x-ratelimit-remaining': z.string(),
|
|
1478
|
+
'x-ratelimit-reset': z.string(),
|
|
1479
|
+
}),
|
|
1480
|
+
events: {
|
|
1481
|
+
result: z.object({ success: z.boolean() }),
|
|
1482
|
+
},
|
|
1483
|
+
})
|
|
1484
|
+
```
|
|
1485
|
+
|
|
1486
|
+
In your handler, set headers using `reply.header()`:
|
|
1487
|
+
|
|
1488
|
+
```ts
|
|
1489
|
+
handlers: {
|
|
1490
|
+
json: async (request, reply) => {
|
|
1491
|
+
reply.header('x-ratelimit-limit', '100')
|
|
1492
|
+
reply.header('x-ratelimit-remaining', '99')
|
|
1493
|
+
reply.header('x-ratelimit-reset', '1640000000')
|
|
1494
|
+
return { result: 'success' }
|
|
1495
|
+
},
|
|
1496
|
+
sse: async (request, connection) => { /* ... */ },
|
|
1497
|
+
}
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
|
|
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
|
+
|
|
1355
1569
|
### Implementing Dual-Mode Controllers
|
|
1356
1570
|
|
|
1357
|
-
Dual-mode controllers use `
|
|
1571
|
+
Dual-mode controllers use `buildHandler` to define both JSON and SSE handlers:
|
|
1358
1572
|
|
|
1359
1573
|
```ts
|
|
1360
1574
|
import {
|
|
1361
1575
|
AbstractDualModeController,
|
|
1362
|
-
|
|
1576
|
+
buildHandler,
|
|
1363
1577
|
type BuildFastifyDualModeRoutesReturnType,
|
|
1364
1578
|
type DualModeControllerConfig,
|
|
1365
1579
|
} from 'opinionated-machine'
|
|
@@ -1388,24 +1602,24 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
|
|
|
1388
1602
|
return {
|
|
1389
1603
|
chatCompletion: {
|
|
1390
1604
|
contract: ChatDualModeController.contracts.chatCompletion,
|
|
1391
|
-
handlers:
|
|
1605
|
+
handlers: buildHandler(chatCompletionContract, {
|
|
1392
1606
|
// JSON mode - return complete response
|
|
1393
|
-
json: async (
|
|
1394
|
-
const result = await this.aiService.complete(
|
|
1607
|
+
json: async (request, _reply) => {
|
|
1608
|
+
const result = await this.aiService.complete(request.body.message)
|
|
1395
1609
|
return {
|
|
1396
1610
|
reply: result.text,
|
|
1397
1611
|
usage: { tokens: result.tokenCount },
|
|
1398
1612
|
}
|
|
1399
1613
|
},
|
|
1400
1614
|
// SSE mode - stream response chunks
|
|
1401
|
-
sse: async (
|
|
1615
|
+
sse: async (request, connection) => {
|
|
1402
1616
|
let totalTokens = 0
|
|
1403
|
-
for await (const chunk of this.aiService.stream(
|
|
1404
|
-
await
|
|
1617
|
+
for await (const chunk of this.aiService.stream(request.body.message)) {
|
|
1618
|
+
await connection.send('chunk', { delta: chunk.text })
|
|
1405
1619
|
totalTokens += chunk.tokenCount ?? 0
|
|
1406
1620
|
}
|
|
1407
|
-
await
|
|
1408
|
-
|
|
1621
|
+
await connection.send('done', { usage: { total: totalTokens } })
|
|
1622
|
+
return success('disconnect')
|
|
1409
1623
|
},
|
|
1410
1624
|
}),
|
|
1411
1625
|
options: {
|
|
@@ -1419,7 +1633,7 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
|
|
|
1419
1633
|
},
|
|
1420
1634
|
// Optional: SSE lifecycle hooks
|
|
1421
1635
|
onConnect: (conn) => console.log('Client connected:', conn.id),
|
|
1422
|
-
|
|
1636
|
+
onClose: (conn, reason) => console.log(`Client disconnected (${reason}):`, conn.id),
|
|
1423
1637
|
},
|
|
1424
1638
|
},
|
|
1425
1639
|
}
|
|
@@ -1427,14 +1641,14 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
|
|
|
1427
1641
|
}
|
|
1428
1642
|
```
|
|
1429
1643
|
|
|
1430
|
-
**Handler
|
|
1644
|
+
**Handler Signatures:**
|
|
1431
1645
|
|
|
1432
|
-
| Mode |
|
|
1433
|
-
| ---- |
|
|
1434
|
-
| `json` | `
|
|
1435
|
-
| `sse` | `
|
|
1646
|
+
| Mode | Signature |
|
|
1647
|
+
| ---- | --------- |
|
|
1648
|
+
| `json` | `(request, reply) => Response` |
|
|
1649
|
+
| `sse` | `(request, connection) => Either<Error, SSEHandlerResult>` |
|
|
1436
1650
|
|
|
1437
|
-
The `json` handler must return a value matching `jsonResponse` schema. The `sse` handler uses `
|
|
1651
|
+
The `json` handler must return a value matching `jsonResponse` schema. The `sse` handler uses `connection.send()` for type-safe event streaming.
|
|
1438
1652
|
|
|
1439
1653
|
### Registering Dual-Mode Controllers
|
|
1440
1654
|
|
|
@@ -1517,6 +1731,21 @@ curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
|
|
|
1517
1731
|
curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
|
|
1518
1732
|
```
|
|
1519
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
|
+
|
|
1520
1749
|
### Testing Dual-Mode Controllers
|
|
1521
1750
|
|
|
1522
1751
|
Test both JSON and SSE modes:
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,11 @@ export { AbstractController, type BuildRoutesReturnType } from './lib/AbstractCo
|
|
|
2
2
|
export { AbstractModule, type MandatoryNameAndRegistrationPair, type UnionToIntersection, } from './lib/AbstractModule.js';
|
|
3
3
|
export { AbstractTestContextFactory, type CreateTestContextParams, } from './lib/AbstractTestContextFactory.js';
|
|
4
4
|
export type { NestedPartial } from './lib/configUtils.js';
|
|
5
|
+
export * from './lib/contracts/index.js';
|
|
5
6
|
export { type DependencyInjectionOptions, DIContext, type RegisterDependenciesParams, } from './lib/DIContext.js';
|
|
6
7
|
export { ENABLE_ALL, isAnyMessageQueueConsumerEnabled, isEnqueuedJobWorkersEnabled, isJobQueueEnabled, isMessageQueueConsumerEnabled, isPeriodicJobEnabled, resolveJobQueuesEnabled, } from './lib/diConfigUtils.js';
|
|
7
8
|
export * from './lib/dualmode/index.js';
|
|
8
9
|
export * from './lib/resolverFunctions.js';
|
|
10
|
+
export * from './lib/routes/index.js';
|
|
9
11
|
export * from './lib/sse/index.js';
|
|
10
12
|
export * from './lib/testing/index.js';
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
export { AbstractController } from './lib/AbstractController.js';
|
|
2
2
|
export { AbstractModule, } from './lib/AbstractModule.js';
|
|
3
3
|
export { AbstractTestContextFactory, } from './lib/AbstractTestContextFactory.js';
|
|
4
|
+
// Contracts (unified builder)
|
|
5
|
+
export * from './lib/contracts/index.js';
|
|
4
6
|
export { DIContext, } from './lib/DIContext.js';
|
|
5
7
|
export { ENABLE_ALL, isAnyMessageQueueConsumerEnabled, isEnqueuedJobWorkersEnabled, isJobQueueEnabled, isMessageQueueConsumerEnabled, isPeriodicJobEnabled, resolveJobQueuesEnabled, } from './lib/diConfigUtils.js';
|
|
6
8
|
// Dual-mode (SSE + JSON)
|
|
7
9
|
export * from './lib/dualmode/index.js';
|
|
8
10
|
export * from './lib/resolverFunctions.js';
|
|
11
|
+
// Routes (unified route builder)
|
|
12
|
+
export * from './lib/routes/index.js';
|
|
9
13
|
// SSE
|
|
10
14
|
export * from './lib/sse/index.js';
|
|
11
15
|
// SSE testing utilities
|