opinionated-machine 6.3.0 → 6.5.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 +282 -137
- package/dist/lib/contracts/contractBuilders.d.ts +59 -92
- package/dist/lib/contracts/contractBuilders.js +94 -18
- package/dist/lib/contracts/contractBuilders.js.map +1 -1
- package/dist/lib/dualmode/AbstractDualModeController.d.ts +1 -1
- package/dist/lib/dualmode/AbstractDualModeController.js +1 -1
- package/dist/lib/dualmode/dualModeContracts.d.ts +71 -9
- package/dist/lib/dualmode/dualModeContracts.js +12 -1
- package/dist/lib/dualmode/dualModeContracts.js.map +1 -1
- package/dist/lib/dualmode/index.d.ts +2 -1
- package/dist/lib/dualmode/index.js +1 -0
- package/dist/lib/dualmode/index.js.map +1 -1
- package/dist/lib/routes/fastifyRouteBuilder.d.ts +3 -3
- package/dist/lib/routes/fastifyRouteBuilder.js +199 -69
- package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -1
- package/dist/lib/routes/fastifyRouteTypes.d.ts +316 -67
- package/dist/lib/routes/fastifyRouteTypes.js +32 -22
- package/dist/lib/routes/fastifyRouteTypes.js.map +1 -1
- package/dist/lib/routes/fastifyRouteUtils.d.ts +94 -8
- package/dist/lib/routes/fastifyRouteUtils.js +285 -45
- package/dist/lib/routes/fastifyRouteUtils.js.map +1 -1
- package/dist/lib/routes/index.d.ts +2 -3
- package/dist/lib/routes/index.js +1 -3
- package/dist/lib/routes/index.js.map +1 -1
- package/dist/lib/sse/AbstractSSEController.d.ts +13 -13
- package/dist/lib/sse/AbstractSSEController.js +3 -3
- package/dist/lib/sse/AbstractSSEController.js.map +1 -1
- package/dist/lib/sse/{SSEConnectionSpy.d.ts → SSESessionSpy.d.ts} +8 -8
- package/dist/lib/sse/{SSEConnectionSpy.js → SSESessionSpy.js} +4 -4
- package/dist/lib/sse/SSESessionSpy.js.map +1 -0
- package/dist/lib/sse/index.d.ts +2 -2
- package/dist/lib/sse/index.js +1 -1
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/sse/sseContracts.d.ts +3 -3
- package/dist/lib/testing/index.d.ts +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +7 -7
- package/dist/lib/testing/sseTestTypes.d.ts +1 -1
- package/package.json +7 -7
- package/dist/lib/sse/SSEConnectionSpy.js.map +0 -1
package/README.md
CHANGED
|
@@ -45,8 +45,8 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
45
45
|
- [parseSSEBuffer](#parsessebuffer)
|
|
46
46
|
- [ParsedSSEEvent Type](#parsedsseevent-type)
|
|
47
47
|
- [Testing SSE Controllers](#testing-sse-controllers)
|
|
48
|
-
- [
|
|
49
|
-
- [
|
|
48
|
+
- [SSESessionSpy API](#ssesessionspy-api)
|
|
49
|
+
- [Session Monitoring](#session-monitoring)
|
|
50
50
|
- [SSE Test Utilities](#sse-test-utilities)
|
|
51
51
|
- [Quick Reference](#quick-reference)
|
|
52
52
|
- [Inject vs HTTP Comparison](#inject-vs-http-comparison)
|
|
@@ -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 `
|
|
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
|
|
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
|
-
|
|
439
|
+
requestBody: z.object({
|
|
440
440
|
message: z.string(),
|
|
441
441
|
stream: z.literal(true),
|
|
442
442
|
}),
|
|
@@ -469,9 +469,8 @@ SSE controllers extend `AbstractSSEController` and must implement a two-paramete
|
|
|
469
469
|
import {
|
|
470
470
|
AbstractSSEController,
|
|
471
471
|
buildHandler,
|
|
472
|
-
success,
|
|
473
472
|
type SSEControllerConfig,
|
|
474
|
-
type
|
|
473
|
+
type SSESession
|
|
475
474
|
} from 'opinionated-machine'
|
|
476
475
|
|
|
477
476
|
type Contracts = {
|
|
@@ -501,50 +500,53 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
|
|
|
501
500
|
contract: NotificationsSSEController.contracts.notificationsStream,
|
|
502
501
|
handlers: this.handleStream,
|
|
503
502
|
options: {
|
|
504
|
-
onConnect: (
|
|
505
|
-
|
|
503
|
+
onConnect: (session) => this.onConnect(session),
|
|
504
|
+
onClose: (session, reason) => this.onClose(session, reason),
|
|
506
505
|
},
|
|
507
506
|
},
|
|
508
507
|
}
|
|
509
508
|
}
|
|
510
509
|
|
|
511
510
|
// Handler with automatic type inference from contract
|
|
512
|
-
//
|
|
511
|
+
// sse.start(mode) returns a session with type-safe event sending
|
|
513
512
|
private handleStream = buildHandler(notificationsContract, {
|
|
514
|
-
sse: async (request,
|
|
513
|
+
sse: async (request, sse) => {
|
|
515
514
|
// request.query is typed from contract: { userId?: string }
|
|
516
515
|
const userId = request.query.userId ?? 'anonymous'
|
|
517
|
-
|
|
516
|
+
|
|
517
|
+
// Start streaming with 'keepAlive' mode - stays open for external events
|
|
518
|
+
// Sends HTTP 200 + SSE headers immediately
|
|
519
|
+
const session = sse.start('keepAlive', { context: { userId } })
|
|
518
520
|
|
|
519
521
|
// For external triggers (subscriptions, timers, message queues), use sendEventInternal.
|
|
520
|
-
//
|
|
521
|
-
// like subscription handlers execute later, outside this function, so they can't access
|
|
522
|
+
// session.send is only available within this handler's scope - external callbacks
|
|
523
|
+
// like subscription handlers execute later, outside this function, so they can't access session.
|
|
522
524
|
// sendEventInternal is a controller method, so it's accessible from any callback.
|
|
523
525
|
// It provides autocomplete for all event names defined in the controller's contracts.
|
|
524
526
|
this.notificationService.subscribe(userId, async (notification) => {
|
|
525
|
-
await this.sendEventInternal(
|
|
527
|
+
await this.sendEventInternal(session.id, {
|
|
526
528
|
event: 'notification',
|
|
527
529
|
data: notification,
|
|
528
530
|
})
|
|
529
531
|
})
|
|
530
532
|
|
|
531
|
-
// For direct sending within the handler, use the
|
|
533
|
+
// For direct sending within the handler, use the session's send method.
|
|
532
534
|
// It provides stricter per-route typing (only events from this specific contract).
|
|
533
|
-
await
|
|
535
|
+
await session.send('notification', { id: 'welcome', message: 'Connected!' })
|
|
534
536
|
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
+
// 'keepAlive' mode: handler returns, but connection stays open for subscription events
|
|
538
|
+
// Connection closes when client disconnects or server calls closeConnection()
|
|
537
539
|
},
|
|
538
540
|
})
|
|
539
541
|
|
|
540
|
-
private onConnect = (
|
|
541
|
-
console.log('Client connected:',
|
|
542
|
+
private onConnect = (session: SSESession) => {
|
|
543
|
+
console.log('Client connected:', session.id)
|
|
542
544
|
}
|
|
543
545
|
|
|
544
|
-
private
|
|
545
|
-
const userId =
|
|
546
|
+
private onClose = (session: SSESession, reason: SSECloseReason) => {
|
|
547
|
+
const userId = session.context?.userId as string
|
|
546
548
|
this.notificationService.unsubscribe(userId)
|
|
547
|
-
console.log(
|
|
549
|
+
console.log(`Client disconnected (${reason}):`, session.id)
|
|
548
550
|
}
|
|
549
551
|
}
|
|
550
552
|
```
|
|
@@ -558,7 +560,7 @@ import {
|
|
|
558
560
|
AbstractSSEController,
|
|
559
561
|
buildHandler,
|
|
560
562
|
type SSEControllerConfig,
|
|
561
|
-
type
|
|
563
|
+
type SSESession
|
|
562
564
|
} from 'opinionated-machine'
|
|
563
565
|
|
|
564
566
|
class ChatSSEController extends AbstractSSEController<Contracts> {
|
|
@@ -571,20 +573,23 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
|
|
|
571
573
|
}
|
|
572
574
|
|
|
573
575
|
// Handler with automatic type inference from contract
|
|
574
|
-
//
|
|
576
|
+
// sse.start(mode) returns session with fully typed send()
|
|
575
577
|
private handleChatCompletion = buildHandler(chatCompletionContract, {
|
|
576
|
-
sse: async (request,
|
|
578
|
+
sse: async (request, sse) => {
|
|
577
579
|
// request.body is typed as { message: string; stream: true }
|
|
578
580
|
// request.query, request.params, request.headers all typed from contract
|
|
579
581
|
const words = request.body.message.split(' ')
|
|
580
582
|
|
|
583
|
+
// Start streaming with 'autoClose' mode - closes after handler completes
|
|
584
|
+
// Sends HTTP 200 + SSE headers immediately
|
|
585
|
+
const session = sse.start('autoClose')
|
|
586
|
+
|
|
581
587
|
for (const word of words) {
|
|
582
|
-
//
|
|
583
|
-
await
|
|
588
|
+
// session.send() provides compile-time type checking for event names and data
|
|
589
|
+
await session.send('chunk', { content: word })
|
|
584
590
|
}
|
|
585
591
|
|
|
586
|
-
//
|
|
587
|
-
return success('disconnect')
|
|
592
|
+
// 'autoClose' mode: connection closes automatically when handler returns
|
|
588
593
|
},
|
|
589
594
|
})
|
|
590
595
|
|
|
@@ -602,15 +607,17 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
|
|
|
602
607
|
You can also use `InferSSERequest<Contract>` for manual type annotation when needed:
|
|
603
608
|
|
|
604
609
|
```ts
|
|
605
|
-
import { type InferSSERequest, type
|
|
610
|
+
import { type InferSSERequest, type SSEContext, type SSESession } from 'opinionated-machine'
|
|
606
611
|
|
|
607
612
|
private handleStream = async (
|
|
608
613
|
request: InferSSERequest<typeof chatCompletionContract>,
|
|
609
|
-
|
|
614
|
+
sse: SSEContext<typeof chatCompletionContract['events']>,
|
|
610
615
|
) => {
|
|
611
616
|
// request.body, request.params, etc. all typed from contract
|
|
612
|
-
|
|
613
|
-
|
|
617
|
+
const session = sse.start('autoClose')
|
|
618
|
+
// session.send() is typed based on contract events
|
|
619
|
+
await session.send('chunk', { content: 'hello' })
|
|
620
|
+
// 'autoClose' mode: connection closes when handler returns
|
|
614
621
|
}
|
|
615
622
|
```
|
|
616
623
|
|
|
@@ -691,10 +698,10 @@ await this.broadcast({
|
|
|
691
698
|
data: { message: 'Server maintenance in 5 minutes' },
|
|
692
699
|
})
|
|
693
700
|
|
|
694
|
-
// Broadcast to
|
|
701
|
+
// Broadcast to sessions matching a predicate
|
|
695
702
|
await this.broadcastIf(
|
|
696
703
|
{ event: 'channel-update', data: { channelId: '123', newMessage: msg } },
|
|
697
|
-
(
|
|
704
|
+
(session) => session.context.channelId === '123',
|
|
698
705
|
)
|
|
699
706
|
```
|
|
700
707
|
|
|
@@ -702,17 +709,17 @@ Both methods return the number of clients the message was successfully sent to.
|
|
|
702
709
|
|
|
703
710
|
### Controller-Level Hooks
|
|
704
711
|
|
|
705
|
-
Override these optional methods on your controller for global
|
|
712
|
+
Override these optional methods on your controller for global session handling:
|
|
706
713
|
|
|
707
714
|
```ts
|
|
708
715
|
class MySSEController extends AbstractSSEController<Contracts> {
|
|
709
|
-
// Called AFTER
|
|
710
|
-
protected onConnectionEstablished(
|
|
716
|
+
// Called AFTER session is registered (for all routes)
|
|
717
|
+
protected onConnectionEstablished(session: SSESession): void {
|
|
711
718
|
this.metrics.incrementConnections()
|
|
712
719
|
}
|
|
713
720
|
|
|
714
|
-
// Called BEFORE
|
|
715
|
-
protected onConnectionClosed(
|
|
721
|
+
// Called BEFORE session is unregistered (for all routes)
|
|
722
|
+
protected onConnectionClosed(session: SSESession): void {
|
|
716
723
|
this.metrics.decrementConnections()
|
|
717
724
|
}
|
|
718
725
|
}
|
|
@@ -735,10 +742,10 @@ public buildSSERoutes() {
|
|
|
735
742
|
reply.code(403).send({ error: 'Forbidden' })
|
|
736
743
|
}
|
|
737
744
|
},
|
|
738
|
-
onConnect: (
|
|
739
|
-
|
|
745
|
+
onConnect: (session) => console.log('Admin connected'),
|
|
746
|
+
onClose: (session, reason) => console.log(`Admin disconnected (${reason})`),
|
|
740
747
|
// Handle client reconnection with Last-Event-ID
|
|
741
|
-
onReconnect: async (
|
|
748
|
+
onReconnect: async (session, lastEventId) => {
|
|
742
749
|
// Return events to replay, or handle manually
|
|
743
750
|
return this.getEventsSince(lastEventId)
|
|
744
751
|
},
|
|
@@ -753,12 +760,66 @@ public buildSSERoutes() {
|
|
|
753
760
|
**Available route options:**
|
|
754
761
|
|
|
755
762
|
| Option | Description |
|
|
756
|
-
|
|
757
|
-
| `preHandler` | Authentication/authorization hook that runs before SSE
|
|
763
|
+
| -------- | ------------- |
|
|
764
|
+
| `preHandler` | Authentication/authorization hook that runs before SSE session |
|
|
758
765
|
| `onConnect` | Called after client connects (SSE handshake complete) |
|
|
759
|
-
| `
|
|
766
|
+
| `onClose` | Called when session closes (client disconnect, network failure, or server close). Receives `(session, reason)` where reason is `'server'` or `'client'` |
|
|
760
767
|
| `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
|
|
761
768
|
| `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
|
|
769
|
+
| `serializer` | Custom serializer for SSE data (e.g., for custom JSON encoding) |
|
|
770
|
+
| `heartbeatInterval` | Interval in ms for heartbeat keep-alive messages |
|
|
771
|
+
|
|
772
|
+
**onClose reason parameter:**
|
|
773
|
+
- `'server'`: Server explicitly closed the session (via `closeConnection()` or `autoClose` mode)
|
|
774
|
+
- `'client'`: Client closed the session (EventSource.close(), navigation, network failure)
|
|
775
|
+
|
|
776
|
+
```ts
|
|
777
|
+
options: {
|
|
778
|
+
onConnect: (session) => console.log('Client connected'),
|
|
779
|
+
onClose: (session, reason) => {
|
|
780
|
+
console.log(`Session closed (${reason}):`, session.id)
|
|
781
|
+
// reason is 'server' or 'client'
|
|
782
|
+
},
|
|
783
|
+
serializer: (data) => JSON.stringify(data, null, 2), // Pretty-print JSON
|
|
784
|
+
heartbeatInterval: 30000, // Send heartbeat every 30 seconds
|
|
785
|
+
}
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
### SSE Session Methods
|
|
789
|
+
|
|
790
|
+
The `session` object returned by `sse.start(mode)` provides several useful methods:
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
private handleStream = buildHandler(streamContract, {
|
|
794
|
+
sse: async (request, sse) => {
|
|
795
|
+
const session = sse.start('autoClose')
|
|
796
|
+
|
|
797
|
+
// Check if session is still active
|
|
798
|
+
if (session.isConnected()) {
|
|
799
|
+
await session.send('status', { connected: true })
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Get raw writable stream for advanced use cases (e.g., pipeline)
|
|
803
|
+
const stream = session.getStream()
|
|
804
|
+
|
|
805
|
+
// Stream messages from an async iterable with automatic validation
|
|
806
|
+
async function* generateMessages() {
|
|
807
|
+
yield { event: 'message' as const, data: { text: 'Hello' } }
|
|
808
|
+
yield { event: 'message' as const, data: { text: 'World' } }
|
|
809
|
+
}
|
|
810
|
+
await session.sendStream(generateMessages())
|
|
811
|
+
|
|
812
|
+
// 'autoClose' mode: connection closes when handler returns
|
|
813
|
+
},
|
|
814
|
+
})
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
| Method | Description |
|
|
818
|
+
| -------- | ------------- |
|
|
819
|
+
| `send(event, data, options?)` | Send a typed event (validates against contract schema) |
|
|
820
|
+
| `isConnected()` | Check if the session is still active |
|
|
821
|
+
| `getStream()` | Get the underlying `WritableStream` for advanced use cases |
|
|
822
|
+
| `sendStream(messages)` | Stream messages from an `AsyncIterable` with validation |
|
|
762
823
|
|
|
763
824
|
### Graceful Shutdown
|
|
764
825
|
|
|
@@ -779,11 +840,11 @@ if (!sent) {
|
|
|
779
840
|
}
|
|
780
841
|
```
|
|
781
842
|
|
|
782
|
-
**Lifecycle hook errors** (`onConnect`, `onReconnect`, `
|
|
843
|
+
**Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
|
|
783
844
|
- All lifecycle hooks are wrapped in try/catch to prevent crashes
|
|
784
845
|
- If a `logger` is provided in route options, errors are logged with context
|
|
785
846
|
- If no logger is provided, errors are silently ignored
|
|
786
|
-
- The
|
|
847
|
+
- The session lifecycle continues even if a hook throws
|
|
787
848
|
|
|
788
849
|
```ts
|
|
789
850
|
// Provide a logger to capture lifecycle errors
|
|
@@ -794,8 +855,8 @@ public buildSSERoutes() {
|
|
|
794
855
|
handlers: this.handleStream,
|
|
795
856
|
options: {
|
|
796
857
|
logger: this.logger, // pino-compatible logger
|
|
797
|
-
onConnect: (
|
|
798
|
-
|
|
858
|
+
onConnect: (session) => { /* may throw */ },
|
|
859
|
+
onClose: (session, reason) => { /* may throw */ },
|
|
799
860
|
},
|
|
800
861
|
},
|
|
801
862
|
}
|
|
@@ -804,84 +865,80 @@ public buildSSERoutes() {
|
|
|
804
865
|
|
|
805
866
|
### Long-lived Connections vs Request-Response Streaming
|
|
806
867
|
|
|
807
|
-
SSE
|
|
808
|
-
|
|
809
|
-
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:
|
|
868
|
+
SSE session lifetime is determined by the mode passed to `sse.start(mode)`:
|
|
810
869
|
|
|
811
870
|
```ts
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
//
|
|
815
|
-
// Return success('maintain_connection') - keep connection open for external events
|
|
816
|
-
// Return failure(error) - handle error and close connection
|
|
871
|
+
// sse.start('autoClose') - close connection when handler returns (request-response pattern)
|
|
872
|
+
// sse.start('keepAlive') - keep connection open for external events (subscription pattern)
|
|
873
|
+
// sse.respond(code, body) - send HTTP response before streaming (early return)
|
|
817
874
|
```
|
|
818
875
|
|
|
819
|
-
**Long-lived
|
|
820
|
-
- Handler
|
|
821
|
-
-
|
|
876
|
+
**Long-lived sessions** (notifications, live updates):
|
|
877
|
+
- Handler starts streaming with `sse.start('keepAlive')`
|
|
878
|
+
- Session stays open indefinitely after handler returns
|
|
822
879
|
- Events are sent later via callbacks using `sendEventInternal()`
|
|
823
|
-
- **Client closes
|
|
880
|
+
- **Client closes session** when done (e.g., `eventSource.close()` or navigating away)
|
|
824
881
|
- Server cleans up via `onConnectionClosed()` hook
|
|
825
882
|
|
|
826
883
|
```ts
|
|
827
|
-
import { success } from 'opinionated-machine'
|
|
828
|
-
|
|
829
884
|
private handleStream = buildHandler(streamContract, {
|
|
830
|
-
sse: async (request,
|
|
885
|
+
sse: async (request, sse) => {
|
|
886
|
+
// Start streaming with 'keepAlive' mode - stays open for external events
|
|
887
|
+
const session = sse.start('keepAlive')
|
|
888
|
+
|
|
831
889
|
// Set up subscription - events sent via callback AFTER handler returns
|
|
832
|
-
this.service.subscribe(
|
|
833
|
-
this.sendEventInternal(
|
|
890
|
+
this.service.subscribe(session.id, (data) => {
|
|
891
|
+
this.sendEventInternal(session.id, { event: 'update', data })
|
|
834
892
|
})
|
|
835
|
-
//
|
|
836
|
-
return success('maintain_connection')
|
|
893
|
+
// 'keepAlive' mode: handler returns, but connection stays open
|
|
837
894
|
},
|
|
838
895
|
})
|
|
839
896
|
|
|
840
897
|
// Clean up when client disconnects
|
|
841
|
-
protected onConnectionClosed(
|
|
842
|
-
this.service.unsubscribe(
|
|
898
|
+
protected onConnectionClosed(session: SSESession): void {
|
|
899
|
+
this.service.unsubscribe(session.id)
|
|
843
900
|
}
|
|
844
901
|
```
|
|
845
902
|
|
|
846
903
|
**Request-response streaming** (AI completions):
|
|
847
|
-
- Handler
|
|
848
|
-
- Use `
|
|
849
|
-
-
|
|
904
|
+
- Handler starts streaming with `sse.start('autoClose')`
|
|
905
|
+
- Use `session.send()` for type-safe event sending within the handler
|
|
906
|
+
- Session automatically closes when handler returns
|
|
850
907
|
|
|
851
908
|
```ts
|
|
852
|
-
import { success } from 'opinionated-machine'
|
|
853
|
-
|
|
854
909
|
private handleChatCompletion = buildHandler(chatCompletionContract, {
|
|
855
|
-
sse: async (request,
|
|
856
|
-
|
|
910
|
+
sse: async (request, sse) => {
|
|
911
|
+
// Start streaming with 'autoClose' mode - closes when handler returns
|
|
912
|
+
const session = sse.start('autoClose')
|
|
857
913
|
|
|
914
|
+
const words = request.body.message.split(' ')
|
|
858
915
|
for (const word of words) {
|
|
859
|
-
await
|
|
916
|
+
await session.send('chunk', { content: word })
|
|
860
917
|
}
|
|
861
|
-
await
|
|
918
|
+
await session.send('done', { totalTokens: words.length })
|
|
862
919
|
|
|
863
|
-
//
|
|
864
|
-
return success('disconnect')
|
|
920
|
+
// 'autoClose' mode: connection closes automatically when handler returns
|
|
865
921
|
},
|
|
866
922
|
})
|
|
867
923
|
```
|
|
868
924
|
|
|
869
|
-
**Error handling:**
|
|
925
|
+
**Error handling before streaming:**
|
|
870
926
|
|
|
871
|
-
|
|
927
|
+
Use `sse.respond(code, body)` to return an HTTP response before streaming starts. This is useful for any early return: validation errors, not found, redirects, etc.
|
|
872
928
|
|
|
873
929
|
```ts
|
|
874
|
-
import { success, failure } from 'opinionated-machine'
|
|
875
|
-
|
|
876
930
|
private handleStream = buildHandler(streamContract, {
|
|
877
|
-
sse: async (request,
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
return
|
|
882
|
-
} catch (err) {
|
|
883
|
-
return failure(err instanceof Error ? err : new Error(String(err)))
|
|
931
|
+
sse: async (request, sse) => {
|
|
932
|
+
// Early return BEFORE starting stream - can return any HTTP response
|
|
933
|
+
const entity = await this.service.find(request.params.id)
|
|
934
|
+
if (!entity) {
|
|
935
|
+
return sse.respond(404, { error: 'Entity not found' })
|
|
884
936
|
}
|
|
937
|
+
|
|
938
|
+
// Validation passed - start streaming with autoClose mode
|
|
939
|
+
const session = sse.start('autoClose')
|
|
940
|
+
await session.send('data', entity)
|
|
941
|
+
// Connection closes automatically when handler returns
|
|
885
942
|
},
|
|
886
943
|
})
|
|
887
944
|
|
|
@@ -1057,47 +1114,47 @@ describe('NotificationsSSEController', () => {
|
|
|
1057
1114
|
})
|
|
1058
1115
|
```
|
|
1059
1116
|
|
|
1060
|
-
###
|
|
1117
|
+
### SSESessionSpy API
|
|
1061
1118
|
|
|
1062
1119
|
The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
|
|
1063
1120
|
|
|
1064
1121
|
```ts
|
|
1065
|
-
// Wait for a
|
|
1066
|
-
const
|
|
1122
|
+
// Wait for a session to be established (with timeout)
|
|
1123
|
+
const session = await controller.connectionSpy.waitForConnection({ timeout: 5000 })
|
|
1067
1124
|
|
|
1068
|
-
// Wait for a
|
|
1069
|
-
const
|
|
1125
|
+
// Wait for a session matching a predicate (useful for multiple sessions)
|
|
1126
|
+
const session = await controller.connectionSpy.waitForConnection({
|
|
1070
1127
|
timeout: 5000,
|
|
1071
|
-
predicate: (
|
|
1128
|
+
predicate: (s) => s.request.url.includes('/api/notifications'),
|
|
1072
1129
|
})
|
|
1073
1130
|
|
|
1074
|
-
// Check if a specific
|
|
1075
|
-
const isConnected = controller.connectionSpy.isConnected(
|
|
1131
|
+
// Check if a specific session is active
|
|
1132
|
+
const isConnected = controller.connectionSpy.isConnected(sessionId)
|
|
1076
1133
|
|
|
1077
|
-
// Wait for a specific
|
|
1078
|
-
await controller.connectionSpy.waitForDisconnection(
|
|
1134
|
+
// Wait for a specific session to disconnect
|
|
1135
|
+
await controller.connectionSpy.waitForDisconnection(sessionId, { timeout: 5000 })
|
|
1079
1136
|
|
|
1080
|
-
// Get all
|
|
1137
|
+
// Get all session events (connect/disconnect history)
|
|
1081
1138
|
const events = controller.connectionSpy.getEvents()
|
|
1082
1139
|
|
|
1083
|
-
// Clear event history and claimed
|
|
1140
|
+
// Clear event history and claimed sessions between tests
|
|
1084
1141
|
controller.connectionSpy.clear()
|
|
1085
1142
|
```
|
|
1086
1143
|
|
|
1087
|
-
**Note**: `waitForConnection` tracks "claimed"
|
|
1144
|
+
**Note**: `waitForConnection` tracks "claimed" sessions internally. Each call returns a unique unclaimed session, allowing sequential waits for the same URL path without returning the same session twice. This is used internally by `SSEHttpClient.connect()` with `awaitServerConnection`.
|
|
1088
1145
|
|
|
1089
|
-
###
|
|
1146
|
+
### Session Monitoring
|
|
1090
1147
|
|
|
1091
|
-
Controllers have access to utility methods for monitoring
|
|
1148
|
+
Controllers have access to utility methods for monitoring sessions:
|
|
1092
1149
|
|
|
1093
1150
|
```ts
|
|
1094
|
-
// Get count of active
|
|
1151
|
+
// Get count of active sessions
|
|
1095
1152
|
const count = this.getConnectionCount()
|
|
1096
1153
|
|
|
1097
|
-
// Get all active
|
|
1098
|
-
const
|
|
1154
|
+
// Get all active sessions (for iteration/inspection)
|
|
1155
|
+
const sessions = this.getConnections()
|
|
1099
1156
|
|
|
1100
|
-
// Check if
|
|
1157
|
+
// Check if session spy is enabled (useful for conditional logic)
|
|
1101
1158
|
if (this.hasConnectionSpy()) {
|
|
1102
1159
|
// ...
|
|
1103
1160
|
}
|
|
@@ -1107,9 +1164,9 @@ if (this.hasConnectionSpy()) {
|
|
|
1107
1164
|
|
|
1108
1165
|
The library provides utilities for testing SSE endpoints.
|
|
1109
1166
|
|
|
1110
|
-
**Two
|
|
1111
|
-
- **Inject** - Uses Fastify's built-in `inject()` to simulate HTTP requests directly in-memory, without network overhead. No `listen()` required. Handler must close the
|
|
1112
|
-
- **Real HTTP** - Actual HTTP
|
|
1167
|
+
**Two transport methods:**
|
|
1168
|
+
- **Inject** - Uses Fastify's built-in `inject()` to simulate HTTP requests directly in-memory, without network overhead. No `listen()` required. Handler must close the session for the request to complete.
|
|
1169
|
+
- **Real HTTP** - Actual HTTP via `fetch()`. Requires the server to be listening. Supports long-lived sessions.
|
|
1113
1170
|
|
|
1114
1171
|
#### Quick Reference
|
|
1115
1172
|
|
|
@@ -1142,7 +1199,7 @@ import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
|
|
|
1142
1199
|
const server = await SSETestServer.create(async (app) => {
|
|
1143
1200
|
app.get('/api/events', async (request, reply) => {
|
|
1144
1201
|
reply.sse({ event: 'message', data: { hello: 'world' } })
|
|
1145
|
-
reply.
|
|
1202
|
+
reply.sse.close()
|
|
1146
1203
|
})
|
|
1147
1204
|
})
|
|
1148
1205
|
|
|
@@ -1360,21 +1417,21 @@ Dual-mode contracts define endpoints that can return **either** a complete JSON
|
|
|
1360
1417
|
- You're building OpenAI-style APIs where `stream: true` triggers SSE
|
|
1361
1418
|
- You need polling fallback for clients that don't support SSE
|
|
1362
1419
|
|
|
1363
|
-
To create a dual-mode contract, include a `
|
|
1364
|
-
- Has `
|
|
1365
|
-
- Has both `
|
|
1420
|
+
To create a dual-mode contract, include a `jsonResponse` schema in your `buildContract` call:
|
|
1421
|
+
- Has `jsonResponse` but no `requestBody` → GET dual-mode route
|
|
1422
|
+
- Has both `jsonResponse` and `requestBody` → POST/PUT/PATCH dual-mode route
|
|
1366
1423
|
|
|
1367
1424
|
```ts
|
|
1368
1425
|
import { z } from 'zod'
|
|
1369
1426
|
import { buildContract } from 'opinionated-machine'
|
|
1370
1427
|
|
|
1371
|
-
// GET dual-mode route (polling or streaming job status) - has
|
|
1428
|
+
// GET dual-mode route (polling or streaming job status) - has jsonResponse, no requestBody
|
|
1372
1429
|
export const jobStatusContract = buildContract({
|
|
1373
1430
|
pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
|
|
1374
1431
|
params: z.object({ jobId: z.string().uuid() }),
|
|
1375
1432
|
query: z.object({ verbose: z.string().optional() }),
|
|
1376
1433
|
requestHeaders: z.object({}),
|
|
1377
|
-
|
|
1434
|
+
jsonResponse: z.object({
|
|
1378
1435
|
status: z.enum(['pending', 'running', 'completed', 'failed']),
|
|
1379
1436
|
progress: z.number(),
|
|
1380
1437
|
result: z.string().optional(),
|
|
@@ -1385,15 +1442,15 @@ export const jobStatusContract = buildContract({
|
|
|
1385
1442
|
},
|
|
1386
1443
|
})
|
|
1387
1444
|
|
|
1388
|
-
// POST dual-mode route (OpenAI-style chat completion) - has both
|
|
1445
|
+
// POST dual-mode route (OpenAI-style chat completion) - has both jsonResponse and requestBody
|
|
1389
1446
|
export const chatCompletionContract = buildContract({
|
|
1390
1447
|
method: 'POST',
|
|
1391
1448
|
pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
|
|
1392
1449
|
params: z.object({ chatId: z.string().uuid() }),
|
|
1393
1450
|
query: z.object({}),
|
|
1394
1451
|
requestHeaders: z.object({ authorization: z.string() }),
|
|
1395
|
-
|
|
1396
|
-
|
|
1452
|
+
requestBody: z.object({ message: z.string() }),
|
|
1453
|
+
jsonResponse: z.object({
|
|
1397
1454
|
reply: z.string(),
|
|
1398
1455
|
usage: z.object({ tokens: z.number() }),
|
|
1399
1456
|
}),
|
|
@@ -1417,8 +1474,8 @@ export const rateLimitedContract = buildContract({
|
|
|
1417
1474
|
params: z.object({}),
|
|
1418
1475
|
query: z.object({}),
|
|
1419
1476
|
requestHeaders: z.object({}),
|
|
1420
|
-
|
|
1421
|
-
|
|
1477
|
+
requestBody: z.object({ data: z.string() }),
|
|
1478
|
+
jsonResponse: z.object({ result: z.string() }),
|
|
1422
1479
|
// Define expected response headers
|
|
1423
1480
|
responseHeaders: z.object({
|
|
1424
1481
|
'x-ratelimit-limit': z.string(),
|
|
@@ -1441,12 +1498,84 @@ handlers: {
|
|
|
1441
1498
|
reply.header('x-ratelimit-reset', '1640000000')
|
|
1442
1499
|
return { result: 'success' }
|
|
1443
1500
|
},
|
|
1444
|
-
sse: async (request,
|
|
1501
|
+
sse: async (request, sse) => {
|
|
1502
|
+
const session = sse.start('autoClose')
|
|
1503
|
+
// ... send events ...
|
|
1504
|
+
// Connection closes automatically when handler returns
|
|
1505
|
+
},
|
|
1445
1506
|
}
|
|
1446
1507
|
```
|
|
1447
1508
|
|
|
1448
1509
|
If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
|
|
1449
1510
|
|
|
1511
|
+
### Multi-Format Responses (Verbose Mode)
|
|
1512
|
+
|
|
1513
|
+
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:
|
|
1514
|
+
|
|
1515
|
+
```ts
|
|
1516
|
+
import { z } from 'zod'
|
|
1517
|
+
import { buildContract } from 'opinionated-machine'
|
|
1518
|
+
|
|
1519
|
+
// Multi-format export endpoint
|
|
1520
|
+
export const exportContract = buildContract({
|
|
1521
|
+
method: 'POST',
|
|
1522
|
+
pathResolver: () => '/api/export',
|
|
1523
|
+
params: z.object({}),
|
|
1524
|
+
query: z.object({}),
|
|
1525
|
+
requestHeaders: z.object({}),
|
|
1526
|
+
requestBody: z.object({
|
|
1527
|
+
data: z.array(z.object({ name: z.string(), value: z.number() })),
|
|
1528
|
+
}),
|
|
1529
|
+
// Define multiple response formats
|
|
1530
|
+
multiFormatResponses: {
|
|
1531
|
+
'application/json': z.object({
|
|
1532
|
+
items: z.array(z.object({ name: z.string(), value: z.number() })),
|
|
1533
|
+
count: z.number(),
|
|
1534
|
+
}),
|
|
1535
|
+
'text/plain': z.string(),
|
|
1536
|
+
'text/csv': z.string(),
|
|
1537
|
+
},
|
|
1538
|
+
events: {
|
|
1539
|
+
progress: z.object({ percent: z.number() }),
|
|
1540
|
+
done: z.object({ format: z.string() }),
|
|
1541
|
+
},
|
|
1542
|
+
})
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
The handler structure changes to `sync` with per-format handlers:
|
|
1546
|
+
|
|
1547
|
+
```ts
|
|
1548
|
+
handlers: buildHandler(exportContract, {
|
|
1549
|
+
sync: {
|
|
1550
|
+
'application/json': (request) => ({
|
|
1551
|
+
items: request.body.data,
|
|
1552
|
+
count: request.body.data.length,
|
|
1553
|
+
}),
|
|
1554
|
+
'text/plain': (request) =>
|
|
1555
|
+
request.body.data.map((item) => `${item.name}: ${item.value}`).join('\n'),
|
|
1556
|
+
'text/csv': (request) =>
|
|
1557
|
+
`name,value\n${request.body.data.map((item) => `${item.name},${item.value}`).join('\n')}`,
|
|
1558
|
+
},
|
|
1559
|
+
sse: async (request, sse) => {
|
|
1560
|
+
// SSE streaming handler
|
|
1561
|
+
const session = sse.start('autoClose')
|
|
1562
|
+
await session.send('done', { totalItems: request.body.data.length })
|
|
1563
|
+
// Connection closes automatically when handler returns
|
|
1564
|
+
},
|
|
1565
|
+
})
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
**Contract styles comparison:**
|
|
1569
|
+
|
|
1570
|
+
| Style | Contract Field | Handler Key | Use Case |
|
|
1571
|
+
|-------|---------------|-------------|----------|
|
|
1572
|
+
| Simplified | `jsonResponse` | `json` | Single JSON format (recommended) |
|
|
1573
|
+
| Verbose | `multiFormatResponses` | `sync` | Multiple formats (JSON, text, CSV, etc.) |
|
|
1574
|
+
|
|
1575
|
+
TypeScript enforces the correct handler structure based on your contract:
|
|
1576
|
+
- `jsonResponse` contracts must use `json` handler
|
|
1577
|
+
- `multiFormatResponses` contracts must use `sync` handlers for all declared formats
|
|
1578
|
+
|
|
1450
1579
|
### Implementing Dual-Mode Controllers
|
|
1451
1580
|
|
|
1452
1581
|
Dual-mode controllers use `buildHandler` to define both JSON and SSE handlers:
|
|
@@ -1493,14 +1622,15 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
|
|
|
1493
1622
|
}
|
|
1494
1623
|
},
|
|
1495
1624
|
// SSE mode - stream response chunks
|
|
1496
|
-
sse: async (request,
|
|
1625
|
+
sse: async (request, sse) => {
|
|
1626
|
+
const session = sse.start('autoClose')
|
|
1497
1627
|
let totalTokens = 0
|
|
1498
1628
|
for await (const chunk of this.aiService.stream(request.body.message)) {
|
|
1499
|
-
await
|
|
1629
|
+
await session.send('chunk', { delta: chunk.text })
|
|
1500
1630
|
totalTokens += chunk.tokenCount ?? 0
|
|
1501
1631
|
}
|
|
1502
|
-
await
|
|
1503
|
-
|
|
1632
|
+
await session.send('done', { usage: { total: totalTokens } })
|
|
1633
|
+
// Connection closes automatically when handler returns
|
|
1504
1634
|
},
|
|
1505
1635
|
}),
|
|
1506
1636
|
options: {
|
|
@@ -1513,8 +1643,8 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
|
|
|
1513
1643
|
}
|
|
1514
1644
|
},
|
|
1515
1645
|
// Optional: SSE lifecycle hooks
|
|
1516
|
-
onConnect: (
|
|
1517
|
-
|
|
1646
|
+
onConnect: (session) => console.log('Client connected:', session.id),
|
|
1647
|
+
onClose: (session, reason) => console.log(`Client disconnected (${reason}):`, session.id),
|
|
1518
1648
|
},
|
|
1519
1649
|
},
|
|
1520
1650
|
}
|
|
@@ -1527,9 +1657,9 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
|
|
|
1527
1657
|
| Mode | Signature |
|
|
1528
1658
|
| ---- | --------- |
|
|
1529
1659
|
| `json` | `(request, reply) => Response` |
|
|
1530
|
-
| `sse` | `(request,
|
|
1660
|
+
| `sse` | `(request, sse) => SSEHandlerResult` |
|
|
1531
1661
|
|
|
1532
|
-
The `json` handler must return a value matching `
|
|
1662
|
+
The `json` handler must return a value matching `jsonResponse` schema. The `sse` handler uses `sse.start(mode)` to begin streaming (`'autoClose'` for request-response, `'keepAlive'` for long-lived sessions) and `session.send()` for type-safe event sending.
|
|
1533
1663
|
|
|
1534
1664
|
### Registering Dual-Mode Controllers
|
|
1535
1665
|
|
|
@@ -1612,6 +1742,21 @@ curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
|
|
|
1612
1742
|
curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
|
|
1613
1743
|
```
|
|
1614
1744
|
|
|
1745
|
+
**Subtype wildcards** are supported for flexible content negotiation:
|
|
1746
|
+
|
|
1747
|
+
```bash
|
|
1748
|
+
# Accept any text format (matches text/plain, text/csv, etc.)
|
|
1749
|
+
curl -H "Accept: text/*" ...
|
|
1750
|
+
|
|
1751
|
+
# Accept any application format (matches application/json, application/xml, etc.)
|
|
1752
|
+
curl -H "Accept: application/*" ...
|
|
1753
|
+
|
|
1754
|
+
# Combine with quality values
|
|
1755
|
+
curl -H "Accept: text/event-stream;q=0.9, application/*;q=0.5" ...
|
|
1756
|
+
```
|
|
1757
|
+
|
|
1758
|
+
The matching priority is: `text/event-stream` (SSE) > exact matches > subtype wildcards > `*/*` > fallback.
|
|
1759
|
+
|
|
1615
1760
|
### Testing Dual-Mode Controllers
|
|
1616
1761
|
|
|
1617
1762
|
Test both JSON and SSE modes:
|