opinionated-machine 6.1.0 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +367 -27
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +2 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/DIContext.d.ts +31 -1
  6. package/dist/lib/DIContext.js +80 -3
  7. package/dist/lib/DIContext.js.map +1 -1
  8. package/dist/lib/dualmode/AbstractDualModeController.d.ts +97 -0
  9. package/dist/lib/dualmode/AbstractDualModeController.js +79 -0
  10. package/dist/lib/dualmode/AbstractDualModeController.js.map +1 -0
  11. package/dist/lib/dualmode/dualModeContracts.d.ts +134 -0
  12. package/dist/lib/dualmode/dualModeContracts.js +77 -0
  13. package/dist/lib/dualmode/dualModeContracts.js.map +1 -0
  14. package/dist/lib/dualmode/dualModeTypes.d.ts +23 -0
  15. package/dist/lib/dualmode/dualModeTypes.js +2 -0
  16. package/dist/lib/dualmode/dualModeTypes.js.map +1 -0
  17. package/dist/lib/dualmode/fastifyDualModeRouteBuilder.d.ts +30 -0
  18. package/dist/lib/dualmode/fastifyDualModeRouteBuilder.js +137 -0
  19. package/dist/lib/dualmode/fastifyDualModeRouteBuilder.js.map +1 -0
  20. package/dist/lib/dualmode/fastifyDualModeTypes.d.ts +153 -0
  21. package/dist/lib/dualmode/fastifyDualModeTypes.js +25 -0
  22. package/dist/lib/dualmode/fastifyDualModeTypes.js.map +1 -0
  23. package/dist/lib/dualmode/index.d.ts +5 -0
  24. package/dist/lib/dualmode/index.js +6 -0
  25. package/dist/lib/dualmode/index.js.map +1 -0
  26. package/dist/lib/resolverFunctions.d.ts +24 -0
  27. package/dist/lib/resolverFunctions.js +31 -0
  28. package/dist/lib/resolverFunctions.js.map +1 -1
  29. package/dist/lib/sse/AbstractSSEController.d.ts +50 -10
  30. package/dist/lib/sse/AbstractSSEController.js +37 -3
  31. package/dist/lib/sse/AbstractSSEController.js.map +1 -1
  32. package/dist/lib/sse/{sseRouteBuilder.d.ts → fastifySSERouteBuilder.d.ts} +7 -4
  33. package/dist/lib/sse/fastifySSERouteBuilder.js +53 -0
  34. package/dist/lib/sse/fastifySSERouteBuilder.js.map +1 -0
  35. package/dist/lib/sse/fastifySSERouteUtils.d.ts +83 -0
  36. package/dist/lib/sse/fastifySSERouteUtils.js +163 -0
  37. package/dist/lib/sse/fastifySSERouteUtils.js.map +1 -0
  38. package/dist/lib/sse/fastifySSETypes.d.ts +200 -0
  39. package/dist/lib/sse/fastifySSETypes.js +47 -0
  40. package/dist/lib/sse/fastifySSETypes.js.map +1 -0
  41. package/dist/lib/sse/index.d.ts +5 -4
  42. package/dist/lib/sse/index.js +4 -2
  43. package/dist/lib/sse/index.js.map +1 -1
  44. package/dist/lib/sse/sseContracts.d.ts +50 -61
  45. package/dist/lib/sse/sseContracts.js +8 -55
  46. package/dist/lib/sse/sseContracts.js.map +1 -1
  47. package/dist/lib/sse/sseTypes.d.ts +26 -146
  48. package/dist/lib/testing/index.d.ts +1 -1
  49. package/dist/lib/testing/index.js +1 -1
  50. package/dist/lib/testing/index.js.map +1 -1
  51. package/dist/lib/testing/sseHttpClient.d.ts +1 -1
  52. package/dist/lib/testing/sseHttpClient.js.map +1 -1
  53. package/dist/lib/testing/sseInjectHelpers.d.ts +3 -10
  54. package/dist/lib/testing/sseInjectHelpers.js +16 -15
  55. package/dist/lib/testing/sseInjectHelpers.js.map +1 -1
  56. package/dist/lib/testing/sseTestTypes.d.ts +3 -3
  57. package/package.json +1 -1
  58. package/dist/lib/sse/sseRouteBuilder.js +0 -176
  59. package/dist/lib/sse/sseRouteBuilder.js.map +0 -1
package/README.md CHANGED
@@ -17,6 +17,7 @@ Very opinionated DI framework for fastify, built on top of awilix
17
17
  - [`asRepositoryClass`](#asrepositoryclasstype-opts)
18
18
  - [`asControllerClass`](#ascontrollerclasstype-opts)
19
19
  - [`asSSEControllerClass`](#asssecontrollerclasstype-sseoptions-opts)
20
+ - [`asDualModeControllerClass`](#asdualmodecontrollerclasstype-sseoptions-opts)
20
21
  - [Message Queue Resolvers](#message-queue-resolvers)
21
22
  - [`asMessageQueueHandlerClass`](#asmessagequeuehandlerclasstype-mqoptions-opts)
22
23
  - [Background Job Resolvers](#background-job-resolvers)
@@ -53,6 +54,13 @@ Very opinionated DI framework for fastify, built on top of awilix
53
54
  - [SSEHttpClient](#ssehttpclient)
54
55
  - [SSEInjectClient](#sseinjectclient)
55
56
  - [Contract-Aware Inject Helpers](#contract-aware-inject-helpers)
57
+ - [Dual-Mode Controllers (SSE + JSON)](#dual-mode-controllers-sse--json)
58
+ - [Overview](#overview)
59
+ - [Defining Dual-Mode Contracts](#defining-dual-mode-contracts)
60
+ - [Implementing Dual-Mode Controllers](#implementing-dual-mode-controllers)
61
+ - [Registering Dual-Mode Controllers](#registering-dual-mode-controllers)
62
+ - [Accept Header Routing](#accept-header-routing)
63
+ - [Testing Dual-Mode Controllers](#testing-dual-mode-controllers)
56
64
 
57
65
  ## Basic usage
58
66
 
@@ -297,6 +305,19 @@ resolveControllers(diOptions: DependencyInjectionOptions) {
297
305
  }
298
306
  ```
299
307
 
308
+ #### `asDualModeControllerClass(Type, sseOptions?, opts?)`
309
+ For dual-mode controller classes that handle both SSE and JSON responses on the same route. Marks the dependency as **private** with `isDualModeController: true` for auto-detection. Inherits all SSE controller features including connection management and graceful shutdown. When `sseOptions.diOptions.isTestMode` is true, enables the connection spy for testing SSE mode.
310
+
311
+ ```ts
312
+ // In resolveControllers()
313
+ resolveControllers(diOptions: DependencyInjectionOptions) {
314
+ return {
315
+ userController: asControllerClass(UserController),
316
+ chatController: asDualModeControllerClass(ChatDualModeController, { diOptions }),
317
+ }
318
+ }
319
+ ```
320
+
300
321
  ### Message Queue Resolvers
301
322
 
302
323
  #### `asMessageQueueHandlerClass(Type, mqOptions, opts?)`
@@ -377,15 +398,26 @@ await app.register(FastifySSEPlugin)
377
398
 
378
399
  ### Defining SSE Contracts
379
400
 
380
- Use `buildSSERoute` for GET-based SSE streams or `buildPayloadSSERoute` for POST/PUT/PATCH streams:
401
+ Use `buildSSEContract` for GET-based SSE streams or `buildPayloadSSEContract` for POST/PUT/PATCH streams. Paths are defined using `pathResolver`, a type-safe function that receives typed params and returns the URL path:
381
402
 
382
403
  ```ts
383
404
  import { z } from 'zod'
384
- import { buildSSERoute, buildPayloadSSERoute } from 'opinionated-machine'
405
+ import { buildSSEContract, buildPayloadSSEContract } from 'opinionated-machine'
385
406
 
386
- // GET-based SSE stream (e.g., notifications)
387
- export const notificationsContract = buildSSERoute({
388
- path: '/api/notifications/stream',
407
+ // GET-based SSE stream with path params
408
+ export const channelStreamContract = buildSSEContract({
409
+ pathResolver: (params) => `/api/channels/${params.channelId}/stream`,
410
+ params: z.object({ channelId: z.string() }),
411
+ query: z.object({}),
412
+ requestHeaders: z.object({}),
413
+ events: {
414
+ message: z.object({ content: z.string() }),
415
+ },
416
+ })
417
+
418
+ // GET-based SSE stream without path params
419
+ export const notificationsContract = buildSSEContract({
420
+ pathResolver: () => '/api/notifications/stream',
389
421
  params: z.object({}),
390
422
  query: z.object({ userId: z.string().optional() }),
391
423
  requestHeaders: z.object({}),
@@ -398,9 +430,9 @@ export const notificationsContract = buildSSERoute({
398
430
  })
399
431
 
400
432
  // POST-based SSE stream (e.g., AI chat completions)
401
- export const chatCompletionContract = buildPayloadSSERoute({
433
+ export const chatCompletionContract = buildPayloadSSEContract({
402
434
  method: 'POST',
403
- path: '/api/chat/completions',
435
+ pathResolver: () => '/api/chat/completions',
404
436
  params: z.object({}),
405
437
  query: z.object({}),
406
438
  requestHeaders: z.object({}),
@@ -476,6 +508,7 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
476
508
  }
477
509
 
478
510
  // Handler with automatic type inference from contract
511
+ // connection.send provides type-safe event sending
479
512
  private handleStream = buildSSEHandler(
480
513
  notificationsContract,
481
514
  async (request, connection) => {
@@ -483,13 +516,21 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
483
516
  const userId = request.query.userId ?? 'anonymous'
484
517
  connection.context = { userId }
485
518
 
486
- // Subscribe to notifications for this user
519
+ // For external triggers (subscriptions, timers, message queues), use sendEventInternal.
520
+ // connection.send is only available within this handler's scope - external callbacks
521
+ // like subscription handlers execute later, outside this function, so they can't access connection.
522
+ // sendEventInternal is a controller method, so it's accessible from any callback.
523
+ // It provides autocomplete for all event names defined in the controller's contracts.
487
524
  this.notificationService.subscribe(userId, async (notification) => {
488
- await this.sendEvent(connection.id, {
525
+ await this.sendEventInternal(connection.id, {
489
526
  event: 'notification',
490
527
  data: notification,
491
528
  })
492
529
  })
530
+
531
+ // For direct sending within the handler, use the connection's send method.
532
+ // It provides stricter per-route typing (only events from this specific contract).
533
+ await connection.send('notification', { id: 'welcome', message: 'Connected!' })
493
534
  },
494
535
  )
495
536
 
@@ -527,6 +568,7 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
527
568
  }
528
569
 
529
570
  // Handler with automatic type inference from contract
571
+ // connection.send is fully typed per-route
530
572
  private handleChatCompletion = buildSSEHandler(
531
573
  chatCompletionContract,
532
574
  async (request, connection) => {
@@ -535,10 +577,8 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
535
577
  const words = request.body.message.split(' ')
536
578
 
537
579
  for (const word of words) {
538
- await this.sendEvent(connection.id, {
539
- event: 'chunk',
540
- data: { content: word },
541
- })
580
+ // connection.send() provides compile-time type checking for event names and data
581
+ await connection.send('chunk', { content: word })
542
582
  }
543
583
 
544
584
  // Gracefully end the stream - all sent data is flushed before connection closes
@@ -560,13 +600,15 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
560
600
  You can also use `InferSSERequest<Contract>` for manual type annotation when needed:
561
601
 
562
602
  ```ts
563
- import { type InferSSERequest } from 'opinionated-machine'
603
+ import { type InferSSERequest, type SSEConnection } from 'opinionated-machine'
564
604
 
565
605
  private handleStream = async (
566
606
  request: InferSSERequest<typeof chatCompletionContract>,
567
- connection: SSEConnection,
607
+ connection: SSEConnection<typeof chatCompletionContract['events']>,
568
608
  ) => {
569
609
  // request.body, request.params, etc. all typed from contract
610
+ // connection.send() is typed based on contract events
611
+ await connection.send('chunk', { content: 'hello' })
570
612
  }
571
613
  ```
572
614
 
@@ -763,13 +805,14 @@ public buildSSERoutes() {
763
805
  **Long-lived connections** (notifications, live updates):
764
806
  - Handler sets up subscriptions and returns
765
807
  - Connection stays open until client disconnects
766
- - Events sent via `sendEvent()` from external triggers
808
+ - Use `sendEventInternal()` for external triggers (typed with union of all contract events)
767
809
 
768
810
  ```ts
769
811
  private handleStream = buildSSEHandler(streamContract, async (request, connection) => {
770
- // Set up subscription
812
+ // External callbacks (subscriptions, timers) can't access `connection` - it's only in this scope.
813
+ // Use sendEventInternal instead - it's a controller method accessible from any callback.
771
814
  this.service.subscribe(connection.id, (data) => {
772
- this.sendEvent(connection.id, { event: 'update', data })
815
+ this.sendEventInternal(connection.id, { event: 'update', data })
773
816
  })
774
817
  // Handler returns, connection stays open
775
818
  })
@@ -777,7 +820,7 @@ private handleStream = buildSSEHandler(streamContract, async (request, connectio
777
820
 
778
821
  **Request-response streaming** (AI completions):
779
822
  - Handler sends all events and closes connection
780
- - Similar to regular HTTP but with streaming body
823
+ - Use `connection.send` for type-safe event sending
781
824
 
782
825
  ```ts
783
826
  private handleChatCompletion = buildSSEHandler(chatCompletionContract, async (request, connection) => {
@@ -785,16 +828,11 @@ private handleChatCompletion = buildSSEHandler(chatCompletionContract, async (re
785
828
  const words = request.body.message.split(' ')
786
829
 
787
830
  for (const word of words) {
788
- await this.sendEvent(connection.id, {
789
- event: 'chunk',
790
- data: { content: word },
791
- })
831
+ // connection.send() provides compile-time type checking for event names and data
832
+ await connection.send('chunk', { content: word })
792
833
  }
793
834
 
794
- await this.sendEvent(connection.id, {
795
- event: 'done',
796
- data: { totalTokens: words.length },
797
- })
835
+ await connection.send('done', { totalTokens: words.length })
798
836
 
799
837
  // Gracefully end the stream - all sent data is flushed before connection closes
800
838
  this.closeConnection(connection.id)
@@ -1254,3 +1292,305 @@ const result = await closed
1254
1292
  const events = parseSSEEvents(result.body)
1255
1293
  ```
1256
1294
 
1295
+ ## Dual-Mode Controllers (SSE + JSON)
1296
+
1297
+ Dual-mode controllers handle both SSE streaming and JSON responses on the same route path, automatically branching based on the `Accept` header. This is ideal for APIs that support both real-time streaming and traditional request-response patterns.
1298
+
1299
+ ### Overview
1300
+
1301
+ | Accept Header | Response Mode |
1302
+ | ------------- | ------------- |
1303
+ | `text/event-stream` | SSE streaming |
1304
+ | `application/json` | JSON response |
1305
+ | `*/*` or missing | JSON (default, configurable) |
1306
+
1307
+ Dual-mode controllers extend `AbstractDualModeController` which inherits from `AbstractSSEController`, providing access to all SSE features (connection management, broadcasting, lifecycle hooks) while adding JSON response support.
1308
+
1309
+ ### Defining Dual-Mode Contracts
1310
+
1311
+ Use `buildDualModeContract` for GET routes or `buildPayloadDualModeContract` for POST/PUT/PATCH routes. The key difference from SSE contracts is the addition of `jsonResponse` schema:
1312
+
1313
+ ```ts
1314
+ import { z } from 'zod'
1315
+ import { buildDualModeContract, buildPayloadDualModeContract } from 'opinionated-machine'
1316
+
1317
+ // GET dual-mode route (polling or streaming job status)
1318
+ export const jobStatusContract = buildDualModeContract({
1319
+ pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
1320
+ params: z.object({ jobId: z.string().uuid() }),
1321
+ query: z.object({ verbose: z.string().optional() }),
1322
+ requestHeaders: z.object({}),
1323
+ jsonResponse: z.object({
1324
+ status: z.enum(['pending', 'running', 'completed', 'failed']),
1325
+ progress: z.number(),
1326
+ result: z.string().optional(),
1327
+ }),
1328
+ events: {
1329
+ progress: z.object({ percent: z.number(), message: z.string().optional() }),
1330
+ done: z.object({ result: z.string() }),
1331
+ },
1332
+ })
1333
+
1334
+ // POST dual-mode route (OpenAI-style chat completion)
1335
+ export const chatCompletionContract = buildPayloadDualModeContract({
1336
+ method: 'POST',
1337
+ pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
1338
+ params: z.object({ chatId: z.string().uuid() }),
1339
+ query: z.object({}),
1340
+ requestHeaders: z.object({ authorization: z.string() }),
1341
+ body: z.object({ message: z.string() }),
1342
+ jsonResponse: z.object({
1343
+ reply: z.string(),
1344
+ usage: z.object({ tokens: z.number() }),
1345
+ }),
1346
+ events: {
1347
+ chunk: z.object({ delta: z.string() }),
1348
+ done: z.object({ usage: z.object({ total: z.number() }) }),
1349
+ },
1350
+ })
1351
+ ```
1352
+
1353
+ **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
+
1355
+ ### Implementing Dual-Mode Controllers
1356
+
1357
+ Dual-mode controllers use `buildDualModeHandler` to define both JSON and SSE handlers:
1358
+
1359
+ ```ts
1360
+ import {
1361
+ AbstractDualModeController,
1362
+ buildDualModeHandler,
1363
+ type BuildFastifyDualModeRoutesReturnType,
1364
+ type DualModeControllerConfig,
1365
+ } from 'opinionated-machine'
1366
+
1367
+ type Contracts = {
1368
+ chatCompletion: typeof chatCompletionContract
1369
+ }
1370
+
1371
+ type Dependencies = {
1372
+ aiService: AIService
1373
+ }
1374
+
1375
+ export class ChatDualModeController extends AbstractDualModeController<Contracts> {
1376
+ public static contracts = {
1377
+ chatCompletion: chatCompletionContract,
1378
+ } as const
1379
+
1380
+ private readonly aiService: AIService
1381
+
1382
+ constructor(deps: Dependencies, config?: DualModeControllerConfig) {
1383
+ super(deps, config)
1384
+ this.aiService = deps.aiService
1385
+ }
1386
+
1387
+ public buildDualModeRoutes(): BuildFastifyDualModeRoutesReturnType<Contracts> {
1388
+ return {
1389
+ chatCompletion: {
1390
+ contract: ChatDualModeController.contracts.chatCompletion,
1391
+ handlers: buildDualModeHandler(chatCompletionContract, {
1392
+ // JSON mode - return complete response
1393
+ json: async (ctx) => {
1394
+ const result = await this.aiService.complete(ctx.request.body.message)
1395
+ return {
1396
+ reply: result.text,
1397
+ usage: { tokens: result.tokenCount },
1398
+ }
1399
+ },
1400
+ // SSE mode - stream response chunks
1401
+ sse: async (ctx) => {
1402
+ let totalTokens = 0
1403
+ for await (const chunk of this.aiService.stream(ctx.request.body.message)) {
1404
+ await ctx.connection.send('chunk', { delta: chunk.text })
1405
+ totalTokens += chunk.tokenCount ?? 0
1406
+ }
1407
+ await ctx.connection.send('done', { usage: { total: totalTokens } })
1408
+ this.closeConnection(ctx.connection.id)
1409
+ },
1410
+ }),
1411
+ options: {
1412
+ // Optional: set SSE as default mode (instead of JSON)
1413
+ defaultMode: 'sse',
1414
+ // Optional: route-level authentication
1415
+ preHandler: (request, reply) => {
1416
+ if (!request.headers.authorization) {
1417
+ return Promise.resolve(reply.code(401).send({ error: 'Unauthorized' }))
1418
+ }
1419
+ },
1420
+ // Optional: SSE lifecycle hooks
1421
+ onConnect: (conn) => console.log('Client connected:', conn.id),
1422
+ onDisconnect: (conn) => console.log('Client disconnected:', conn.id),
1423
+ },
1424
+ },
1425
+ }
1426
+ }
1427
+ }
1428
+ ```
1429
+
1430
+ **Handler Context:**
1431
+
1432
+ | Mode | Context Properties |
1433
+ | ---- | ------------------ |
1434
+ | `json` | `ctx.mode`, `ctx.request`, `ctx.reply` |
1435
+ | `sse` | `ctx.mode`, `ctx.connection`, `ctx.request` |
1436
+
1437
+ The `json` handler must return a value matching `jsonResponse` schema. The `sse` handler uses `ctx.connection.send()` for type-safe event streaming.
1438
+
1439
+ ### Registering Dual-Mode Controllers
1440
+
1441
+ Use `asDualModeControllerClass` in your module:
1442
+
1443
+ ```ts
1444
+ import {
1445
+ AbstractModule,
1446
+ asControllerClass,
1447
+ asDualModeControllerClass,
1448
+ asServiceClass,
1449
+ } from 'opinionated-machine'
1450
+
1451
+ export class ChatModule extends AbstractModule<Dependencies> {
1452
+ resolveDependencies() {
1453
+ return {
1454
+ aiService: asServiceClass(AIService),
1455
+ }
1456
+ }
1457
+
1458
+ resolveControllers(diOptions: DependencyInjectionOptions) {
1459
+ return {
1460
+ // REST controller
1461
+ usersController: asControllerClass(UsersController),
1462
+ // Dual-mode controller (auto-detected via isDualModeController flag)
1463
+ chatController: asDualModeControllerClass(ChatDualModeController, { diOptions }),
1464
+ }
1465
+ }
1466
+ }
1467
+ ```
1468
+
1469
+ Register dual-mode routes after the `@fastify/sse` plugin:
1470
+
1471
+ ```ts
1472
+ const app = fastify()
1473
+ app.setValidatorCompiler(validatorCompiler)
1474
+ app.setSerializerCompiler(serializerCompiler)
1475
+
1476
+ // Register @fastify/sse plugin
1477
+ await app.register(FastifySSEPlugin)
1478
+
1479
+ // Register routes
1480
+ context.registerRoutes(app) // REST routes
1481
+ context.registerSSERoutes(app) // SSE-only routes
1482
+ context.registerDualModeRoutes(app) // Dual-mode routes
1483
+
1484
+ // Check if controllers exist before registration (optional)
1485
+ if (context.hasDualModeControllers()) {
1486
+ context.registerDualModeRoutes(app)
1487
+ }
1488
+
1489
+ await app.ready()
1490
+ ```
1491
+
1492
+ ### Accept Header Routing
1493
+
1494
+ The `Accept` header determines response mode:
1495
+
1496
+ ```bash
1497
+ # JSON mode (complete response)
1498
+ curl -X POST http://localhost:3000/api/chats/123/completions \
1499
+ -H "Content-Type: application/json" \
1500
+ -H "Accept: application/json" \
1501
+ -d '{"message": "Hello world"}'
1502
+
1503
+ # SSE mode (streaming response)
1504
+ curl -X POST http://localhost:3000/api/chats/123/completions \
1505
+ -H "Content-Type: application/json" \
1506
+ -H "Accept: text/event-stream" \
1507
+ -d '{"message": "Hello world"}'
1508
+ ```
1509
+
1510
+ **Quality values** are supported for content negotiation:
1511
+
1512
+ ```bash
1513
+ # Prefer JSON (higher quality value)
1514
+ curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
1515
+
1516
+ # Prefer SSE (higher quality value)
1517
+ curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
1518
+ ```
1519
+
1520
+ ### Testing Dual-Mode Controllers
1521
+
1522
+ Test both JSON and SSE modes:
1523
+
1524
+ ```ts
1525
+ import { createContainer } from 'awilix'
1526
+ import { DIContext, SSETestServer, SSEInjectClient } from 'opinionated-machine'
1527
+
1528
+ describe('ChatDualModeController', () => {
1529
+ let server: SSETestServer
1530
+ let injectClient: SSEInjectClient
1531
+
1532
+ beforeEach(async () => {
1533
+ const container = createContainer({ injectionMode: 'PROXY' })
1534
+ const context = new DIContext(container, { isTestMode: true }, {})
1535
+ context.registerDependencies({ modules: [new ChatModule()] }, undefined)
1536
+
1537
+ server = await SSETestServer.create(
1538
+ (app) => {
1539
+ context.registerDualModeRoutes(app)
1540
+ },
1541
+ {
1542
+ configureApp: (app) => {
1543
+ app.setValidatorCompiler(validatorCompiler)
1544
+ app.setSerializerCompiler(serializerCompiler)
1545
+ },
1546
+ setup: () => ({ context }),
1547
+ },
1548
+ )
1549
+
1550
+ injectClient = new SSEInjectClient(server.app)
1551
+ })
1552
+
1553
+ afterEach(async () => {
1554
+ await server.resources.context.destroy()
1555
+ await server.close()
1556
+ })
1557
+
1558
+ it('returns JSON for Accept: application/json', async () => {
1559
+ const response = await server.app.inject({
1560
+ method: 'POST',
1561
+ url: '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
1562
+ headers: {
1563
+ 'content-type': 'application/json',
1564
+ accept: 'application/json',
1565
+ authorization: 'Bearer token',
1566
+ },
1567
+ payload: { message: 'Hello' },
1568
+ })
1569
+
1570
+ expect(response.statusCode).toBe(200)
1571
+ expect(response.headers['content-type']).toContain('application/json')
1572
+
1573
+ const body = JSON.parse(response.body)
1574
+ expect(body).toHaveProperty('reply')
1575
+ expect(body).toHaveProperty('usage')
1576
+ })
1577
+
1578
+ it('streams SSE for Accept: text/event-stream', async () => {
1579
+ const conn = await injectClient.connectWithBody(
1580
+ '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
1581
+ { message: 'Hello' },
1582
+ { headers: { authorization: 'Bearer token' } },
1583
+ )
1584
+
1585
+ expect(conn.getStatusCode()).toBe(200)
1586
+ expect(conn.getHeaders()['content-type']).toContain('text/event-stream')
1587
+
1588
+ const events = conn.getReceivedEvents()
1589
+ const chunks = events.filter((e) => e.event === 'chunk')
1590
+ const doneEvents = events.filter((e) => e.event === 'done')
1591
+
1592
+ expect(chunks.length).toBeGreaterThan(0)
1593
+ expect(doneEvents).toHaveLength(1)
1594
+ })
1595
+ })
1596
+
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { AbstractTestContextFactory, type CreateTestContextParams, } from './lib
4
4
  export type { NestedPartial } from './lib/configUtils.js';
5
5
  export { type DependencyInjectionOptions, DIContext, type RegisterDependenciesParams, } from './lib/DIContext.js';
6
6
  export { ENABLE_ALL, isAnyMessageQueueConsumerEnabled, isEnqueuedJobWorkersEnabled, isJobQueueEnabled, isMessageQueueConsumerEnabled, isPeriodicJobEnabled, resolveJobQueuesEnabled, } from './lib/diConfigUtils.js';
7
+ export * from './lib/dualmode/index.js';
7
8
  export * from './lib/resolverFunctions.js';
8
9
  export * from './lib/sse/index.js';
9
10
  export * from './lib/testing/index.js';
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ export { AbstractModule, } from './lib/AbstractModule.js';
3
3
  export { AbstractTestContextFactory, } from './lib/AbstractTestContextFactory.js';
4
4
  export { DIContext, } from './lib/DIContext.js';
5
5
  export { ENABLE_ALL, isAnyMessageQueueConsumerEnabled, isEnqueuedJobWorkersEnabled, isJobQueueEnabled, isMessageQueueConsumerEnabled, isPeriodicJobEnabled, resolveJobQueuesEnabled, } from './lib/diConfigUtils.js';
6
+ // Dual-mode (SSE + JSON)
7
+ export * from './lib/dualmode/index.js';
6
8
  export * from './lib/resolverFunctions.js';
7
9
  // SSE
8
10
  export * from './lib/sse/index.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAA8B,MAAM,6BAA6B,CAAA;AAC5F,OAAO,EACL,cAAc,GAGf,MAAM,yBAAyB,CAAA;AAChC,OAAO,EACL,0BAA0B,GAE3B,MAAM,qCAAqC,CAAA;AAE5C,OAAO,EAEL,SAAS,GAEV,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,UAAU,EACV,gCAAgC,EAChC,2BAA2B,EAC3B,iBAAiB,EACjB,6BAA6B,EAC7B,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,wBAAwB,CAAA;AAC/B,cAAc,4BAA4B,CAAA;AAC1C,MAAM;AACN,cAAc,oBAAoB,CAAA;AAClC,wBAAwB;AACxB,cAAc,wBAAwB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAA8B,MAAM,6BAA6B,CAAA;AAC5F,OAAO,EACL,cAAc,GAGf,MAAM,yBAAyB,CAAA;AAChC,OAAO,EACL,0BAA0B,GAE3B,MAAM,qCAAqC,CAAA;AAE5C,OAAO,EAEL,SAAS,GAEV,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,UAAU,EACV,gCAAgC,EAChC,2BAA2B,EAC3B,iBAAiB,EACjB,6BAA6B,EAC7B,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,wBAAwB,CAAA;AAC/B,yBAAyB;AACzB,cAAc,yBAAyB,CAAA;AACvC,cAAc,4BAA4B,CAAA;AAC1C,MAAM;AACN,cAAc,oBAAoB,CAAA;AAClC,wBAAwB;AACxB,cAAc,wBAAwB,CAAA"}
@@ -4,7 +4,8 @@ import type { FastifyInstance } from 'fastify';
4
4
  import type { AbstractModule } from './AbstractModule.js';
5
5
  import { type NestedPartial } from './configUtils.js';
6
6
  import type { ENABLE_ALL } from './diConfigUtils.js';
7
- import { type RegisterSSERoutesOptions } from './sse/sseRouteBuilder.js';
7
+ import type { RegisterDualModeRoutesOptions } from './dualmode/fastifyDualModeTypes.js';
8
+ import { type RegisterSSERoutesOptions } from './sse/fastifySSERouteBuilder.js';
8
9
  export type RegisterDependenciesParams<Dependencies, Config, ExternalDependencies> = {
9
10
  modules: readonly AbstractModule<unknown, ExternalDependencies>[];
10
11
  secondaryModules?: readonly AbstractModule<unknown, ExternalDependencies>[];
@@ -30,6 +31,7 @@ export declare class DIContext<Dependencies extends object, Config extends objec
30
31
  readonly diContainer: AwilixContainer<Dependencies>;
31
32
  private readonly controllerResolvers;
32
33
  private readonly sseControllerNames;
34
+ private readonly dualModeControllerNames;
33
35
  private readonly appConfig;
34
36
  constructor(diContainer: AwilixContainer, options: DependencyInjectionOptions, appConfig: Config, awilixManager?: AwilixManager);
35
37
  private registerModule;
@@ -40,6 +42,11 @@ export declare class DIContext<Dependencies extends object, Config extends objec
40
42
  * Use this to conditionally call registerSSERoutes().
41
43
  */
42
44
  hasSSEControllers(): boolean;
45
+ /**
46
+ * Check if any dual-mode controllers are registered.
47
+ * Use this to conditionally call registerDualModeRoutes().
48
+ */
49
+ hasDualModeControllers(): boolean;
43
50
  /**
44
51
  * Register SSE routes with the Fastify app.
45
52
  *
@@ -59,6 +66,29 @@ export declare class DIContext<Dependencies extends object, Config extends objec
59
66
  * ```
60
67
  */
61
68
  registerSSERoutes(app: FastifyInstance<any, any, any, any>, options?: RegisterSSERoutesOptions): void;
69
+ /**
70
+ * Register dual-mode routes with the Fastify app.
71
+ *
72
+ * Dual-mode routes handle both SSE streaming and JSON responses on the
73
+ * same path, automatically branching based on the `Accept` header.
74
+ *
75
+ * Must be called separately from registerRoutes() and registerSSERoutes().
76
+ * Requires @fastify/sse plugin to be registered on the app.
77
+ *
78
+ * @param app - Fastify instance with @fastify/sse registered
79
+ * @param options - Optional configuration for dual-mode routes
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * // Register @fastify/sse plugin first
84
+ * await app.register(fastifySSE, { heartbeatInterval: 30000 })
85
+ *
86
+ * // Then register dual-mode routes
87
+ * context.registerDualModeRoutes(app)
88
+ * ```
89
+ */
90
+ registerDualModeRoutes(app: FastifyInstance<any, any, any, any>, options?: RegisterDualModeRoutesOptions): void;
91
+ private applyDualModeRouteOptions;
62
92
  private applySSERouteOptions;
63
93
  private applyPreHandlers;
64
94
  private applyRateLimit;