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.
- package/README.md +367 -27
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/DIContext.d.ts +31 -1
- package/dist/lib/DIContext.js +80 -3
- package/dist/lib/DIContext.js.map +1 -1
- package/dist/lib/dualmode/AbstractDualModeController.d.ts +97 -0
- package/dist/lib/dualmode/AbstractDualModeController.js +79 -0
- package/dist/lib/dualmode/AbstractDualModeController.js.map +1 -0
- package/dist/lib/dualmode/dualModeContracts.d.ts +134 -0
- package/dist/lib/dualmode/dualModeContracts.js +77 -0
- package/dist/lib/dualmode/dualModeContracts.js.map +1 -0
- package/dist/lib/dualmode/dualModeTypes.d.ts +23 -0
- package/dist/lib/dualmode/dualModeTypes.js +2 -0
- package/dist/lib/dualmode/dualModeTypes.js.map +1 -0
- package/dist/lib/dualmode/fastifyDualModeRouteBuilder.d.ts +30 -0
- package/dist/lib/dualmode/fastifyDualModeRouteBuilder.js +137 -0
- package/dist/lib/dualmode/fastifyDualModeRouteBuilder.js.map +1 -0
- package/dist/lib/dualmode/fastifyDualModeTypes.d.ts +153 -0
- package/dist/lib/dualmode/fastifyDualModeTypes.js +25 -0
- package/dist/lib/dualmode/fastifyDualModeTypes.js.map +1 -0
- package/dist/lib/dualmode/index.d.ts +5 -0
- package/dist/lib/dualmode/index.js +6 -0
- package/dist/lib/dualmode/index.js.map +1 -0
- package/dist/lib/resolverFunctions.d.ts +24 -0
- package/dist/lib/resolverFunctions.js +31 -0
- package/dist/lib/resolverFunctions.js.map +1 -1
- package/dist/lib/sse/AbstractSSEController.d.ts +50 -10
- package/dist/lib/sse/AbstractSSEController.js +37 -3
- package/dist/lib/sse/AbstractSSEController.js.map +1 -1
- package/dist/lib/sse/{sseRouteBuilder.d.ts → fastifySSERouteBuilder.d.ts} +7 -4
- package/dist/lib/sse/fastifySSERouteBuilder.js +53 -0
- package/dist/lib/sse/fastifySSERouteBuilder.js.map +1 -0
- package/dist/lib/sse/fastifySSERouteUtils.d.ts +83 -0
- package/dist/lib/sse/fastifySSERouteUtils.js +163 -0
- package/dist/lib/sse/fastifySSERouteUtils.js.map +1 -0
- package/dist/lib/sse/fastifySSETypes.d.ts +200 -0
- package/dist/lib/sse/fastifySSETypes.js +47 -0
- package/dist/lib/sse/fastifySSETypes.js.map +1 -0
- package/dist/lib/sse/index.d.ts +5 -4
- package/dist/lib/sse/index.js +4 -2
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/sse/sseContracts.d.ts +50 -61
- package/dist/lib/sse/sseContracts.js +8 -55
- package/dist/lib/sse/sseContracts.js.map +1 -1
- package/dist/lib/sse/sseTypes.d.ts +26 -146
- package/dist/lib/testing/index.d.ts +1 -1
- package/dist/lib/testing/index.js +1 -1
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +1 -1
- package/dist/lib/testing/sseHttpClient.js.map +1 -1
- package/dist/lib/testing/sseInjectHelpers.d.ts +3 -10
- package/dist/lib/testing/sseInjectHelpers.js +16 -15
- package/dist/lib/testing/sseInjectHelpers.js.map +1 -1
- package/dist/lib/testing/sseTestTypes.d.ts +3 -3
- package/package.json +1 -1
- package/dist/lib/sse/sseRouteBuilder.js +0 -176
- 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 `
|
|
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 {
|
|
405
|
+
import { buildSSEContract, buildPayloadSSEContract } from 'opinionated-machine'
|
|
385
406
|
|
|
386
|
-
// GET-based SSE stream
|
|
387
|
-
export const
|
|
388
|
-
|
|
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 =
|
|
433
|
+
export const chatCompletionContract = buildPayloadSSEContract({
|
|
402
434
|
method: 'POST',
|
|
403
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
|
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"}
|
package/dist/lib/DIContext.d.ts
CHANGED
|
@@ -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 {
|
|
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;
|