opinionated-machine 6.14.0 → 6.16.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 +196 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/DIContext.d.ts +2 -0
- package/dist/lib/DIContext.js +34 -20
- package/dist/lib/DIContext.js.map +1 -1
- package/dist/lib/api-contracts/AbstractApiController.d.ts +19 -0
- package/dist/lib/api-contracts/AbstractApiController.js +18 -0
- package/dist/lib/api-contracts/AbstractApiController.js.map +1 -0
- package/dist/lib/api-contracts/apiHandlerTypes.d.ts +116 -0
- package/dist/lib/api-contracts/apiHandlerTypes.js +2 -0
- package/dist/lib/api-contracts/apiHandlerTypes.js.map +1 -0
- package/dist/lib/api-contracts/apiRouteBuilder.d.ts +9 -0
- package/dist/lib/api-contracts/apiRouteBuilder.js +344 -0
- package/dist/lib/api-contracts/apiRouteBuilder.js.map +1 -0
- package/dist/lib/api-contracts/asApiControllerClass.d.ts +24 -0
- package/dist/lib/api-contracts/asApiControllerClass.js +27 -0
- package/dist/lib/api-contracts/asApiControllerClass.js.map +1 -0
- package/dist/lib/api-contracts/index.d.ts +4 -0
- package/dist/lib/api-contracts/index.js +4 -0
- package/dist/lib/api-contracts/index.js.map +1 -0
- package/dist/lib/sse/AbstractSSEController.js +4 -3
- package/dist/lib/sse/AbstractSSEController.js.map +1 -1
- package/dist/lib/sse/index.d.ts +2 -1
- package/dist/lib/sse/index.js +2 -0
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/sse/rooms/SSERoomBroadcaster.d.ts +20 -4
- package/dist/lib/sse/rooms/SSERoomBroadcaster.js +44 -12
- package/dist/lib/sse/rooms/SSERoomBroadcaster.js.map +1 -1
- package/dist/lib/sse/rooms/SSERoomManager.d.ts +5 -2
- package/dist/lib/sse/rooms/SSERoomManager.js +8 -5
- package/dist/lib/sse/rooms/SSERoomManager.js.map +1 -1
- package/dist/lib/sse/rooms/adapters/InMemoryAdapter.d.ts +1 -1
- package/dist/lib/sse/rooms/adapters/InMemoryAdapter.js +1 -1
- package/dist/lib/sse/rooms/adapters/InMemoryAdapter.js.map +1 -1
- package/dist/lib/sse/rooms/index.d.ts +1 -1
- package/dist/lib/sse/rooms/types.d.ts +34 -2
- package/dist/lib/sse/subscriptions/SSESubscriptionManager.d.ts +47 -0
- package/dist/lib/sse/subscriptions/SSESubscriptionManager.js +298 -0
- package/dist/lib/sse/subscriptions/SSESubscriptionManager.js.map +1 -0
- package/dist/lib/sse/subscriptions/defineEventMetadata.d.ts +50 -0
- package/dist/lib/sse/subscriptions/defineEventMetadata.js +44 -0
- package/dist/lib/sse/subscriptions/defineEventMetadata.js.map +1 -0
- package/dist/lib/sse/subscriptions/index.d.ts +3 -0
- package/dist/lib/sse/subscriptions/index.js +3 -0
- package/dist/lib/sse/subscriptions/index.js.map +1 -0
- package/dist/lib/sse/subscriptions/types.d.ts +141 -0
- package/dist/lib/sse/subscriptions/types.js +2 -0
- package/dist/lib/sse/subscriptions/types.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,6 +58,17 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
58
58
|
- [Room Query Methods](#room-query-methods)
|
|
59
59
|
- [Auto-Leave on Disconnect](#auto-leave-on-disconnect)
|
|
60
60
|
- [Multi-Node Deployments with Redis](#multi-node-deployments-with-redis)
|
|
61
|
+
- [SSE Subscriptions](#sse-subscriptions)
|
|
62
|
+
- [Defining Event Metadata](#defining-event-metadata)
|
|
63
|
+
- [Defining Resolvers](#defining-resolvers)
|
|
64
|
+
- [Configuring the Manager](#configuring-the-manager)
|
|
65
|
+
- [Integrating with a Controller](#integrating-with-a-controller)
|
|
66
|
+
- [Publishing Events](#publishing-events)
|
|
67
|
+
- [Refreshing Preferences Mid-Connection](#refreshing-preferences-mid-connection)
|
|
68
|
+
- [Pipeline Semantics](#pipeline-semantics)
|
|
69
|
+
- [Multi-Node Support](#multi-node-support)
|
|
70
|
+
- [Data Loading with layered-loader](#data-loading-with-layered-loader)
|
|
71
|
+
- [Testing](#testing)
|
|
61
72
|
- [SSE Test Utilities](#sse-test-utilities)
|
|
62
73
|
- [Quick Reference](#quick-reference)
|
|
63
74
|
- [Inject vs HTTP Comparison](#inject-vs-http-comparison)
|
|
@@ -1803,6 +1814,191 @@ The Redis adapter uses Pub/Sub for cross-node message propagation. When you call
|
|
|
1803
1814
|
|
|
1804
1815
|
See the [@opinionated-machine/sse-rooms-redis](./packages/sse-rooms-redis/README.md) package for detailed documentation on Redis adapter configuration and usage.
|
|
1805
1816
|
|
|
1817
|
+
### SSE Subscriptions
|
|
1818
|
+
|
|
1819
|
+
SSE Subscriptions add user-centered event filtering on top of SSE Rooms. Users connect once to a universal stream, and a **resolver pipeline** determines which events reach them based on membership, preferences, and arbitrary business rules.
|
|
1820
|
+
|
|
1821
|
+
#### Defining Event Metadata
|
|
1822
|
+
|
|
1823
|
+
Define a discriminated union describing all event scopes, then create type-safe guards with `defineEventMetadata()`:
|
|
1824
|
+
|
|
1825
|
+
```typescript
|
|
1826
|
+
import { defineEventMetadata } from 'opinionated-machine'
|
|
1827
|
+
|
|
1828
|
+
type EventMetadata =
|
|
1829
|
+
| { scope: 'project'; projectId: string }
|
|
1830
|
+
| { scope: 'team'; teamId: string }
|
|
1831
|
+
| { scope: 'global' }
|
|
1832
|
+
|
|
1833
|
+
const meta = defineEventMetadata<EventMetadata>()('scope', ['project', 'team', 'global'])
|
|
1834
|
+
|
|
1835
|
+
// In resolvers, guards narrow the type:
|
|
1836
|
+
if (meta.project(event.metadata)) {
|
|
1837
|
+
event.metadata.projectId // TypeScript knows this is string
|
|
1838
|
+
}
|
|
1839
|
+
```
|
|
1840
|
+
|
|
1841
|
+
#### Defining Resolvers
|
|
1842
|
+
|
|
1843
|
+
Resolvers are stateless filters evaluated in pipeline order. Each resolver can `allow`, `deny`, or `defer`:
|
|
1844
|
+
|
|
1845
|
+
```typescript
|
|
1846
|
+
import type { SubscriptionResolver, SubscriptionContext, FilterVerdict } from 'opinionated-machine'
|
|
1847
|
+
|
|
1848
|
+
class ProjectMembershipResolver {
|
|
1849
|
+
readonly name = 'project-membership'
|
|
1850
|
+
|
|
1851
|
+
async onConnect(ctx: SubscriptionContext<UserCtx>) {
|
|
1852
|
+
const memberships = await this.membershipLoader.get(ctx.userContext.userId)
|
|
1853
|
+
const projectIds = new Set(memberships.map(m => m.projectId))
|
|
1854
|
+
return {
|
|
1855
|
+
userContext: { ...ctx.userContext, projectIds },
|
|
1856
|
+
rooms: Array.from(projectIds).map(id => `project:${id}`),
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
evaluate(ctx: SubscriptionContext<UserCtx>, event: IncomingEvent<EventMetadata>): FilterVerdict {
|
|
1861
|
+
if (meta.project(event.metadata)) {
|
|
1862
|
+
return ctx.userContext.projectIds.has(event.metadata.projectId)
|
|
1863
|
+
? { action: 'allow' }
|
|
1864
|
+
: { action: 'deny', reason: 'not a project member' }
|
|
1865
|
+
}
|
|
1866
|
+
return { action: 'defer' }
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
async refresh(ctx: SubscriptionContext<UserCtx>) {
|
|
1870
|
+
// Re-fetch memberships on demand
|
|
1871
|
+
const memberships = await this.membershipLoader.get(ctx.userContext.userId)
|
|
1872
|
+
const projectIds = new Set(memberships.map(m => m.projectId))
|
|
1873
|
+
return {
|
|
1874
|
+
userContext: { ...ctx.userContext, projectIds },
|
|
1875
|
+
rooms: Array.from(projectIds).map(id => `project:${id}`),
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
```
|
|
1880
|
+
|
|
1881
|
+
#### Configuring the Manager
|
|
1882
|
+
|
|
1883
|
+
```typescript
|
|
1884
|
+
import { SSESubscriptionManager } from 'opinionated-machine'
|
|
1885
|
+
|
|
1886
|
+
const subscriptionManager = new SSESubscriptionManager<UserCtx, EventMetadata>(
|
|
1887
|
+
{
|
|
1888
|
+
resolveUserContext: async (request) => ({
|
|
1889
|
+
userId: request.user.id,
|
|
1890
|
+
projectIds: new Set(),
|
|
1891
|
+
mutedEventTypes: new Set(),
|
|
1892
|
+
}),
|
|
1893
|
+
resolvers: [
|
|
1894
|
+
new ProjectMembershipResolver(membershipLoader),
|
|
1895
|
+
new MutePreferencesResolver(prefsLoader),
|
|
1896
|
+
],
|
|
1897
|
+
defaultPolicy: 'deny',
|
|
1898
|
+
resolveUserId: (ctx) => ctx.userId,
|
|
1899
|
+
},
|
|
1900
|
+
{ sseRoomManager, sseRoomBroadcaster },
|
|
1901
|
+
)
|
|
1902
|
+
```
|
|
1903
|
+
|
|
1904
|
+
#### Integrating with a Controller
|
|
1905
|
+
|
|
1906
|
+
Wire `handleConnect` and `handleDisconnect` into the SSE session lifecycle:
|
|
1907
|
+
|
|
1908
|
+
```typescript
|
|
1909
|
+
class NotificationController extends AbstractSSEController<Contracts> {
|
|
1910
|
+
private handleStream = buildHandler(contract, {
|
|
1911
|
+
sse: (request, sse) => {
|
|
1912
|
+
const session = sse.start('keepAlive')
|
|
1913
|
+
this.subscriptionManager.handleConnect(session).catch(() => {
|
|
1914
|
+
// Handle connection setup failure (e.g., resolver threw)
|
|
1915
|
+
})
|
|
1916
|
+
},
|
|
1917
|
+
}, {
|
|
1918
|
+
onClose: (session) => {
|
|
1919
|
+
this.subscriptionManager.handleDisconnect(session)
|
|
1920
|
+
},
|
|
1921
|
+
})
|
|
1922
|
+
}
|
|
1923
|
+
```
|
|
1924
|
+
|
|
1925
|
+
#### Publishing Events
|
|
1926
|
+
|
|
1927
|
+
```typescript
|
|
1928
|
+
const result = await subscriptionManager.publish({
|
|
1929
|
+
eventName: 'announcement',
|
|
1930
|
+
data: { message: 'New feature released!' },
|
|
1931
|
+
targetRooms: ['project:123'],
|
|
1932
|
+
metadata: { scope: 'project', projectId: '123' },
|
|
1933
|
+
})
|
|
1934
|
+
// result: { delivered: 5, filtered: 2 }
|
|
1935
|
+
```
|
|
1936
|
+
|
|
1937
|
+
`targetRooms` controls routing:
|
|
1938
|
+
- **Specific rooms** (`['project:123']`) — broadcast to those rooms, filter via resolver pipeline
|
|
1939
|
+
- **`undefined`** (omitted) — broadcast to all rooms of all managed connections
|
|
1940
|
+
- **Empty array** (`[]`) — no-op, returns `{ delivered: 0, filtered: 0 }`
|
|
1941
|
+
|
|
1942
|
+
#### Refreshing Preferences Mid-Connection
|
|
1943
|
+
|
|
1944
|
+
When a user updates preferences (e.g., mutes an event type), refresh their active connections:
|
|
1945
|
+
|
|
1946
|
+
```typescript
|
|
1947
|
+
// In your REST endpoint handler:
|
|
1948
|
+
await prefsLoader.invalidateCacheFor(userId)
|
|
1949
|
+
await subscriptionManager.refreshUser(userId)
|
|
1950
|
+
```
|
|
1951
|
+
|
|
1952
|
+
The manager diffs rooms and joins/leaves as needed — no reconnection required.
|
|
1953
|
+
|
|
1954
|
+
#### Pipeline Semantics
|
|
1955
|
+
|
|
1956
|
+
- Resolvers are evaluated in array order
|
|
1957
|
+
- First `deny` short-circuits — event is not delivered
|
|
1958
|
+
- `allow` does not short-circuit — subsequent resolvers can still deny
|
|
1959
|
+
- If all resolvers return `defer`, `defaultPolicy` applies (default: `deny`)
|
|
1960
|
+
- Resolver `evaluate()` errors are treated as `deny`
|
|
1961
|
+
- Resolver `refresh()` errors are caught per-resolver — the failed resolver keeps its previous state while remaining resolvers continue refreshing
|
|
1962
|
+
- Later resolvers in the array receive the accumulated `userContext` from earlier resolvers — use spread (`{ ...ctx.userContext, ...newFields }`) to preserve prior resolver data
|
|
1963
|
+
- `defaultPolicy` defaults to `'deny'` when not specified
|
|
1964
|
+
|
|
1965
|
+
#### Multi-Node Support
|
|
1966
|
+
|
|
1967
|
+
- Metadata flows through the adapter chain (Redis pub/sub) alongside the SSE message
|
|
1968
|
+
- Resolver pipeline runs locally on each node for its own connections
|
|
1969
|
+
- Wire format is a single v1 schema with optional `meta` — older nodes simply have no metadata
|
|
1970
|
+
- Use `layered-loader` for distributed cache invalidation across nodes
|
|
1971
|
+
|
|
1972
|
+
#### Data Loading with layered-loader
|
|
1973
|
+
|
|
1974
|
+
`layered-loader` is recommended (not required) for resolver data loading. It provides in-memory → Redis → DB caching with TTL, refresh-ahead, and distributed invalidation:
|
|
1975
|
+
|
|
1976
|
+
```typescript
|
|
1977
|
+
import { Loader } from 'layered-loader'
|
|
1978
|
+
|
|
1979
|
+
const membershipLoader = new Loader<ProjectMembership[]>({
|
|
1980
|
+
inMemoryCache: { cacheType: 'lru-map', ttlInMsecs: 120_000, maxItems: 500 },
|
|
1981
|
+
asyncCache: new RedisCache(redis, { json: true, ttlInMsecs: 900_000 }),
|
|
1982
|
+
dataSources: [membershipDataSource],
|
|
1983
|
+
})
|
|
1984
|
+
```
|
|
1985
|
+
|
|
1986
|
+
#### Testing
|
|
1987
|
+
|
|
1988
|
+
Create mock resolvers for unit tests:
|
|
1989
|
+
|
|
1990
|
+
```typescript
|
|
1991
|
+
const mockResolver = {
|
|
1992
|
+
name: 'mock',
|
|
1993
|
+
evaluate: vi.fn().mockReturnValue({ action: 'allow' }),
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
const manager = new SSESubscriptionManager(
|
|
1997
|
+
{ resolveUserContext: async () => mockContext, resolvers: [mockResolver] },
|
|
1998
|
+
{ sseRoomManager, sseRoomBroadcaster },
|
|
1999
|
+
)
|
|
2000
|
+
```
|
|
2001
|
+
|
|
1806
2002
|
### SSE Test Utilities
|
|
1807
2003
|
|
|
1808
2004
|
The library provides utilities for testing SSE endpoints.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { AbstractController, type BuildRoutesReturnType } from './lib/AbstractController.js';
|
|
2
2
|
export { AbstractModule, type InferModuleDependencies, type InferPublicModuleDependencies, type MandatoryNameAndRegistrationPair, type PublicDependencies, } from './lib/AbstractModule.js';
|
|
3
3
|
export { AbstractTestContextFactory, type CreateTestContextParams, } from './lib/AbstractTestContextFactory.js';
|
|
4
|
+
export * from './lib/api-contracts/index.js';
|
|
4
5
|
export type { NestedPartial } from './lib/configUtils.js';
|
|
5
6
|
export { type DependencyInjectionOptions, DIContext, type RegisterDependenciesParams, } from './lib/DIContext.js';
|
|
6
7
|
export { ENABLE_ALL, isAnyMessageQueueConsumerEnabled, isEnqueuedJobWorkersEnabled, isJobQueueEnabled, isMessageQueueConsumerEnabled, isPeriodicJobEnabled, resolveJobQueuesEnabled, } from './lib/diConfigUtils.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { AbstractController } from './lib/AbstractController.js';
|
|
2
2
|
export { AbstractModule, } from './lib/AbstractModule.js';
|
|
3
3
|
export { AbstractTestContextFactory, } from './lib/AbstractTestContextFactory.js';
|
|
4
|
+
export * from './lib/api-contracts/index.js';
|
|
4
5
|
export { DIContext, } from './lib/DIContext.js';
|
|
5
6
|
export { ENABLE_ALL, isAnyMessageQueueConsumerEnabled, isEnqueuedJobWorkersEnabled, isJobQueueEnabled, isMessageQueueConsumerEnabled, isPeriodicJobEnabled, resolveJobQueuesEnabled, } from './lib/diConfigUtils.js';
|
|
6
7
|
// Dual-mode (SSE + JSON)
|
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,GAKf,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,iCAAiC;AACjC,cAAc,uBAAuB,CAAA;AACrC,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,GAKf,MAAM,yBAAyB,CAAA;AAChC,OAAO,EACL,0BAA0B,GAE3B,MAAM,qCAAqC,CAAA;AAC5C,cAAc,8BAA8B,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,iCAAiC;AACjC,cAAc,uBAAuB,CAAA;AACrC,MAAM;AACN,cAAc,oBAAoB,CAAA;AAClC,wBAAwB;AACxB,cAAc,wBAAwB,CAAA"}
|
package/dist/lib/DIContext.d.ts
CHANGED
|
@@ -31,8 +31,10 @@ export declare class DIContext<Dependencies extends object, Config extends objec
|
|
|
31
31
|
private readonly controllerResolvers;
|
|
32
32
|
private readonly sseControllerNames;
|
|
33
33
|
private readonly dualModeControllerNames;
|
|
34
|
+
private readonly apiControllerNames;
|
|
34
35
|
private readonly appConfig;
|
|
35
36
|
constructor(diContainer: AwilixContainer<Dependencies>, options: DependencyInjectionOptions, appConfig: Config, awilixManager?: AwilixManager);
|
|
37
|
+
private registerControllers;
|
|
36
38
|
private registerModule;
|
|
37
39
|
registerDependencies(params: RegisterDependenciesParams<Dependencies, Config, ExternalDependencies>, externalDependencies: ExternalDependencies, resolveControllers?: boolean): void;
|
|
38
40
|
registerRoutes(app: FastifyInstance<any, any, any, any>): void;
|
package/dist/lib/DIContext.js
CHANGED
|
@@ -12,6 +12,8 @@ export class DIContext {
|
|
|
12
12
|
sseControllerNames;
|
|
13
13
|
// Dual-mode controller dependency names (resolved from container to preserve singletons)
|
|
14
14
|
dualModeControllerNames;
|
|
15
|
+
// ApiContract controller dependency names (resolved from container to preserve singletons)
|
|
16
|
+
apiControllerNames;
|
|
15
17
|
appConfig;
|
|
16
18
|
constructor(diContainer, options, appConfig, awilixManager) {
|
|
17
19
|
this.options = options;
|
|
@@ -29,6 +31,31 @@ export class DIContext {
|
|
|
29
31
|
this.controllerResolvers = [];
|
|
30
32
|
this.sseControllerNames = [];
|
|
31
33
|
this.dualModeControllerNames = [];
|
|
34
|
+
this.apiControllerNames = [];
|
|
35
|
+
}
|
|
36
|
+
registerControllers(
|
|
37
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller resolver properties are duck-typed
|
|
38
|
+
controllers, targetDiConfig) {
|
|
39
|
+
for (const [name, resolver] of Object.entries(controllers)) {
|
|
40
|
+
if (resolver.isDualModeController) {
|
|
41
|
+
this.dualModeControllerNames.push(name);
|
|
42
|
+
// @ts-expect-error we can't really ensure type-safety here
|
|
43
|
+
targetDiConfig[name] = resolver;
|
|
44
|
+
}
|
|
45
|
+
else if (resolver.isSSEController) {
|
|
46
|
+
this.sseControllerNames.push(name);
|
|
47
|
+
// @ts-expect-error we can't really ensure type-safety here
|
|
48
|
+
targetDiConfig[name] = resolver;
|
|
49
|
+
}
|
|
50
|
+
else if (resolver.isApiController) {
|
|
51
|
+
this.apiControllerNames.push(name);
|
|
52
|
+
// @ts-expect-error we can't really ensure type-safety here
|
|
53
|
+
targetDiConfig[name] = resolver;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
this.controllerResolvers.push(resolver);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
32
59
|
}
|
|
33
60
|
registerModule(module, targetDiConfig, externalDependencies, resolveControllers, isPrimaryModule) {
|
|
34
61
|
const resolvedDIConfig = module.resolveDependencies(this.options, externalDependencies);
|
|
@@ -41,26 +68,7 @@ export class DIContext {
|
|
|
41
68
|
}
|
|
42
69
|
if (isPrimaryModule && resolveControllers) {
|
|
43
70
|
const controllers = module.resolveControllers(this.options);
|
|
44
|
-
|
|
45
|
-
// @ts-expect-error isDualModeController is a custom property on the resolver
|
|
46
|
-
if (resolver.isDualModeController) {
|
|
47
|
-
// Dual-mode controller: register in DI container and track name for route registration
|
|
48
|
-
this.dualModeControllerNames.push(name);
|
|
49
|
-
// @ts-expect-error we can't really ensure type-safety here
|
|
50
|
-
targetDiConfig[name] = resolver;
|
|
51
|
-
// @ts-expect-error isSSEController is a custom property on the resolver
|
|
52
|
-
}
|
|
53
|
-
else if (resolver.isSSEController) {
|
|
54
|
-
// SSE controller: register in DI container and track name for route registration
|
|
55
|
-
this.sseControllerNames.push(name);
|
|
56
|
-
// @ts-expect-error we can't really ensure type-safety here
|
|
57
|
-
targetDiConfig[name] = resolver;
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
// REST controller: add resolver for route registration
|
|
61
|
-
this.controllerResolvers.push(resolver);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
71
|
+
this.registerControllers(controllers, targetDiConfig);
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
registerDependencies(params, externalDependencies, resolveControllers = true) {
|
|
@@ -101,6 +109,12 @@ export class DIContext {
|
|
|
101
109
|
app.route(route);
|
|
102
110
|
}
|
|
103
111
|
}
|
|
112
|
+
for (const controllerName of this.apiControllerNames) {
|
|
113
|
+
const controller = this.diContainer.resolve(controllerName);
|
|
114
|
+
for (const route of controller.routes) {
|
|
115
|
+
app.route(route);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
104
118
|
}
|
|
105
119
|
/**
|
|
106
120
|
* Check if any SSE controllers are registered.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DIContext.js","sourceRoot":"","sources":["../../lib/DIContext.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAE9C,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"DIContext.js","sourceRoot":"","sources":["../../lib/DIContext.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RouteOptions } from 'fastify';
|
|
2
|
+
/**
|
|
3
|
+
* Abstract base class for controllers that use the `ApiContract` API.
|
|
4
|
+
*
|
|
5
|
+
* Concrete controllers declare a `routes` property built with `buildApiRoute()`.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* class UserController extends AbstractApiController {
|
|
10
|
+
* readonly routes = [
|
|
11
|
+
* buildApiRoute(getUser, async (req) => ({ status: 200, body: { id: req.params.id } })),
|
|
12
|
+
* buildApiRoute(streamUpdates, async (_req, sse) => { sse.start('keepAlive') }),
|
|
13
|
+
* ]
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export declare abstract class AbstractApiController {
|
|
18
|
+
abstract readonly routes: RouteOptions[];
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for controllers that use the `ApiContract` API.
|
|
3
|
+
*
|
|
4
|
+
* Concrete controllers declare a `routes` property built with `buildApiRoute()`.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* class UserController extends AbstractApiController {
|
|
9
|
+
* readonly routes = [
|
|
10
|
+
* buildApiRoute(getUser, async (req) => ({ status: 200, body: { id: req.params.id } })),
|
|
11
|
+
* buildApiRoute(streamUpdates, async (_req, sse) => { sse.start('keepAlive') }),
|
|
12
|
+
* ]
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export class AbstractApiController {
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=AbstractApiController.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AbstractApiController.js","sourceRoot":"","sources":["../../../lib/api-contracts/AbstractApiController.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AACH,MAAM,OAAgB,qBAAqB;CAE1C"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ApiContract, ContractNoBody, ContractResponseMode, InferSseSuccessResponses, PayloadApiContract } from '@lokalise/api-contracts';
|
|
2
|
+
import type { FastifyRequest, RouteOptions } from 'fastify';
|
|
3
|
+
import type { z } from 'zod/v4';
|
|
4
|
+
import type { DualModeType } from '../dualmode/dualModeTypes.ts';
|
|
5
|
+
import type { FastifySSERouteOptions, SSEContext, SSEHandlerResult, SyncModeReply } from '../routes/fastifyRouteTypes.ts';
|
|
6
|
+
type NonSseBodyEntry<T> = T extends undefined ? never : T extends {
|
|
7
|
+
_tag: 'SseResponse';
|
|
8
|
+
} ? never : T extends {
|
|
9
|
+
_tag: 'BlobResponse';
|
|
10
|
+
} ? Blob : T extends {
|
|
11
|
+
_tag: 'TextResponse';
|
|
12
|
+
} ? string : T extends {
|
|
13
|
+
_tag: 'AnyOfResponses';
|
|
14
|
+
responses: Array<infer R>;
|
|
15
|
+
} ? NonSseBodyEntry<R> : T extends z.ZodType ? z.output<T> : undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Discriminated union of `{ status, body }` pairs for all non-SSE responses in a contract.
|
|
18
|
+
*
|
|
19
|
+
* Allows non-SSE handlers to return a specific status code and body together without
|
|
20
|
+
* calling `reply.code()` separately.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* async (request) => {
|
|
25
|
+
* if (!valid) return { status: 400, body: { error: 'Bad Request' } }
|
|
26
|
+
* return { id: request.params.id }
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export type InferApiStatusResponse<Contract extends ApiContract> = {
|
|
31
|
+
[K in keyof Contract['responsesByStatusCode']]: NonSseBodyEntry<Contract['responsesByStatusCode'][K]> extends never ? never : {
|
|
32
|
+
status: K;
|
|
33
|
+
body: NonSseBodyEntry<Contract['responsesByStatusCode'][K]>;
|
|
34
|
+
};
|
|
35
|
+
}[keyof Contract['responsesByStatusCode']];
|
|
36
|
+
type InferOptSchema<T, Fallback = unknown> = NonNullable<T> extends z.ZodType ? z.output<NonNullable<T>> : Fallback;
|
|
37
|
+
type InferApiBodyType<Contract extends ApiContract> = Contract extends PayloadApiContract ? Contract['requestBodySchema'] extends typeof ContractNoBody ? undefined : NonNullable<Contract['requestBodySchema']> extends z.ZodType ? z.output<NonNullable<Contract['requestBodySchema']>> : undefined : undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Infer the FastifyRequest type from an ApiContract.
|
|
40
|
+
*
|
|
41
|
+
* Provides properly typed params, querystring, headers, and body.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* const handler = async (request: InferApiRequest<typeof myContract>) => {
|
|
46
|
+
* request.params.userId // typed
|
|
47
|
+
* request.body.name // typed
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export type InferApiRequest<Contract extends ApiContract> = FastifyRequest<{
|
|
52
|
+
Params: InferOptSchema<Contract['requestPathParamsSchema']>;
|
|
53
|
+
Querystring: InferOptSchema<Contract['requestQuerySchema']>;
|
|
54
|
+
Headers: InferOptSchema<Contract['requestHeaderSchema']>;
|
|
55
|
+
Body: InferApiBodyType<Contract>;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* Handler for non-SSE responses from an ApiContract.
|
|
59
|
+
*
|
|
60
|
+
* Always return `{ status, body }` — the framework validates the body against the
|
|
61
|
+
* contract's schema for that status code and sends it.
|
|
62
|
+
*
|
|
63
|
+
* Use `reply.header()` to set response headers when needed.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* async (request) => ({ status: 200, body: { id: request.params.userId } })
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @example With multiple status codes
|
|
71
|
+
* ```typescript
|
|
72
|
+
* async (request) => {
|
|
73
|
+
* if (!valid) return { status: 400, body: { error: 'Bad Request' } }
|
|
74
|
+
* return { status: 200, body: { id: request.params.userId } }
|
|
75
|
+
* }
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export type ApiNonSseHandler<Contract extends ApiContract> = (request: InferApiRequest<Contract>, reply: SyncModeReply) => InferApiStatusResponse<Contract> | Promise<InferApiStatusResponse<Contract>>;
|
|
79
|
+
/**
|
|
80
|
+
* Handler for SSE responses from an ApiContract.
|
|
81
|
+
*
|
|
82
|
+
* Call `sse.start(mode)` to begin streaming or `sse.respond(code, body)` for
|
|
83
|
+
* early HTTP returns before streaming starts.
|
|
84
|
+
*/
|
|
85
|
+
export type ApiSseHandler<Contract extends ApiContract> = (request: InferApiRequest<Contract>, sse: SSEContext<InferSseSuccessResponses<Contract['responsesByStatusCode']>>) => SSEHandlerResult | Promise<SSEHandlerResult>;
|
|
86
|
+
/**
|
|
87
|
+
* Infer the handler shape based on the contract's response mode:
|
|
88
|
+
* - `'non-sse'` — bare `ApiNonSseHandler` function
|
|
89
|
+
* - `'sse'` — bare `ApiSseHandler` function
|
|
90
|
+
* - `'dual'` — `{ nonSse, sse }` object, branched by `Accept` header
|
|
91
|
+
*/
|
|
92
|
+
export type InferApiHandler<Contract extends ApiContract> = [
|
|
93
|
+
ContractResponseMode<Contract['responsesByStatusCode']>
|
|
94
|
+
] extends ['dual'] ? {
|
|
95
|
+
nonSse: ApiNonSseHandler<Contract>;
|
|
96
|
+
sse: ApiSseHandler<Contract>;
|
|
97
|
+
} : [ContractResponseMode<Contract['responsesByStatusCode']>] extends ['sse'] ? ApiSseHandler<Contract> : ApiNonSseHandler<Contract>;
|
|
98
|
+
/**
|
|
99
|
+
* Options for configuring an ApiContract route.
|
|
100
|
+
*
|
|
101
|
+
* Extends Fastify's `RouteOptions` minus the fields the contract provides
|
|
102
|
+
* (`method`, `url`, `schema`, `handler`, `sse`), so any Fastify hook or config
|
|
103
|
+
* (`preHandler`, `onRequest`, `config`, `bodyLimit`, etc.) can be passed directly.
|
|
104
|
+
*
|
|
105
|
+
* SSE lifecycle options (`onConnect`, `onClose`, `onReconnect`) are only
|
|
106
|
+
* relevant for SSE and dual-mode contracts and are ignored for non-SSE routes.
|
|
107
|
+
*/
|
|
108
|
+
export type ApiRouteOptions = Omit<RouteOptions, 'method' | 'url' | 'schema' | 'handler' | 'sse'> & Omit<FastifySSERouteOptions, 'preHandler'> & {
|
|
109
|
+
/**
|
|
110
|
+
* Default response mode for dual-mode routes when the `Accept` header
|
|
111
|
+
* does not express a preference.
|
|
112
|
+
* @default 'json'
|
|
113
|
+
*/
|
|
114
|
+
defaultMode?: DualModeType;
|
|
115
|
+
};
|
|
116
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apiHandlerTypes.js","sourceRoot":"","sources":["../../../lib/api-contracts/apiHandlerTypes.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ApiContract } from '@lokalise/api-contracts';
|
|
2
|
+
import type { RouteOptions } from 'fastify';
|
|
3
|
+
import type { ApiRouteOptions, InferApiHandler } from './apiHandlerTypes.ts';
|
|
4
|
+
/**
|
|
5
|
+
* Build a Fastify `RouteOptions` object from an `ApiContract` + handler.
|
|
6
|
+
*
|
|
7
|
+
* @returns Fastify `RouteOptions` ready to pass to `app.route()`
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildApiRoute<Contract extends ApiContract>(contract: Contract, handler: InferApiHandler<Contract>, options?: ApiRouteOptions): RouteOptions;
|