@smounters/imperium 1.0.3 → 1.1.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/CHANGELOG.md +27 -0
- package/README.md +48 -174
- package/dist/core/container.d.ts +4 -0
- package/dist/core/container.js +22 -6
- package/dist/core/server.js +9 -3
- package/dist/core/types.d.ts +1 -1
- package/dist/decorators/index.d.ts +2 -1
- package/dist/decorators/index.js +2 -1
- package/dist/decorators/rpc.decorators.d.ts +1 -0
- package/dist/decorators/rpc.decorators.js +1 -0
- package/dist/decorators/ws.decorators.d.ts +9 -0
- package/dist/decorators/ws.decorators.js +43 -0
- package/dist/http/adapter.js +6 -1
- package/dist/rpc/adapter.js +8 -0
- package/dist/rpc/index.d.ts +1 -0
- package/dist/rpc/index.js +1 -0
- package/dist/rpc/router-builder.js +13 -11
- package/dist/rpc/streaming-adapter.d.ts +5 -0
- package/dist/rpc/streaming-adapter.js +186 -0
- package/dist/types.d.ts +15 -5
- package/dist/ws/adapter.d.ts +6 -0
- package/dist/ws/adapter.js +168 -0
- package/dist/ws/index.d.ts +3 -0
- package/dist/ws/index.js +2 -0
- package/dist/ws/router-builder.d.ts +4 -0
- package/dist/ws/router-builder.js +25 -0
- package/dist/ws/types.d.ts +18 -0
- package/dist/ws/types.js +1 -0
- package/package.json +25 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@smounters/imperium` are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.1.0 - 2026-03-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Server Streaming RPC** — `async function*` handlers via ConnectRPC. Delivered as SSE (Server-Sent Events) in browsers without WebSocket.
|
|
9
|
+
- **`@RpcAbortSignal()` decorator** — injects `AbortSignal` from `HandlerContext.signal` for detecting client disconnects in streaming handlers.
|
|
10
|
+
- **WebSocket Gateway** — real-time bidirectional communication via `@fastify/websocket` (optional peer dependency).
|
|
11
|
+
- `@WsGateway(path)` — marks a class as a WebSocket gateway, registers on the specified path.
|
|
12
|
+
- `@WsHandler(messageType)` — routes incoming JSON messages by `type` field.
|
|
13
|
+
- `@WsConnection()`, `@WsMessage()`, `@WsRequest()` — parameter decorators for handler methods.
|
|
14
|
+
- Lifecycle hooks: `onConnection(socket, req)`, `onDisconnect(socket)`.
|
|
15
|
+
- Guards execute at connection time (upgrade request).
|
|
16
|
+
- **`./ws` subpath export** — `@smounters/imperium/ws` exposes `registerWsGateways`, `WsGatewayLifecycle`, and related types.
|
|
17
|
+
- `BaseContext` extended with `type: "ws"`, `switchToWs()`, and `WsArgumentsHost` interface.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- `ContextType` is now `"http" | "rpc" | "ws"` (was `"http" | "rpc"`).
|
|
21
|
+
- `RpcParamSource` extended with `"abort_signal"`.
|
|
22
|
+
- RPC router builder no longer throws on `server_streaming` methods; only `client_streaming` and `bidi_streaming` are unsupported.
|
|
23
|
+
- `WsArgumentsHost` methods return `| undefined` for type safety across context switches (no unsafe casts).
|
|
24
|
+
- Peer dependency `@fastify/websocket ^11.0.0` added as optional.
|
|
25
|
+
|
|
26
|
+
### Updated
|
|
27
|
+
- `@types/node` 24.x → 25.x.
|
|
28
|
+
- `@typescript-eslint/*` 8.56 → 8.57.
|
|
29
|
+
- `eslint` 10.0 → 10.1.
|
|
30
|
+
- `fastify` 5.7 → 5.8.
|
|
31
|
+
|
|
5
32
|
## 1.0.3 - 2026-03-20
|
|
6
33
|
|
|
7
34
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,38 +1,24 @@
|
|
|
1
1
|
# @smounters/imperium
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
NestJS-inspired modular DI framework for TypeScript services. Unified HTTP + ConnectRPC + WebSocket server on a single Fastify instance.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Features
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **Module system** with `@Module`, `@Injectable`, guards, pipes, interceptors, filters
|
|
8
|
+
- **HTTP controllers** with `@HttpController`, `@Get`, `@Post`, `@Body`, `@Query`, `@Param`
|
|
9
|
+
- **ConnectRPC** with `@RpcService`, `@RpcMethod` (unary + server streaming)
|
|
10
|
+
- **WebSocket gateway** with `@WsGateway`, `@WsHandler`, message routing, lifecycle hooks
|
|
11
|
+
- **Request-scoped DI** via AsyncLocalStorage (tsyringe-based)
|
|
12
|
+
- **Typed config** via Zod + `ConfigService`
|
|
13
|
+
- **Structured logging** via tslog + `LoggerService`
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
- Unified HTTP + RPC server on one Fastify instance.
|
|
11
|
-
- Request-scoped handler execution.
|
|
12
|
-
- Typed runtime config via `zod` + `ConfigService`.
|
|
13
|
-
- Built-in `LoggerService` (tslog-based).
|
|
14
|
-
- Global enhancer tokens (`APP_GUARD`, `APP_PIPE`, `APP_INTERCEPTOR`, `APP_FILTER`).
|
|
15
|
-
- Multi-provider array injection (`InjectAll`) and manual array resolution (`resolveAll`).
|
|
16
|
-
|
|
17
|
-
## Public Imports
|
|
18
|
-
|
|
19
|
-
Root import is intentionally disabled.
|
|
20
|
-
|
|
21
|
-
Use subpaths only:
|
|
22
|
-
|
|
23
|
-
- `@smounters/imperium/core`
|
|
24
|
-
- `@smounters/imperium/decorators`
|
|
25
|
-
- `@smounters/imperium/services`
|
|
26
|
-
- `@smounters/imperium/pipes`
|
|
27
|
-
- `@smounters/imperium/validation`
|
|
28
|
-
|
|
29
|
-
## Installation
|
|
15
|
+
## Install
|
|
30
16
|
|
|
31
17
|
```bash
|
|
32
|
-
pnpm add @smounters/imperium reflect-metadata tsyringe fastify @connectrpc/connect @connectrpc/connect-fastify zod
|
|
18
|
+
pnpm add @smounters/imperium reflect-metadata tsyringe fastify @connectrpc/connect @connectrpc/connect-fastify zod tslog
|
|
33
19
|
```
|
|
34
20
|
|
|
35
|
-
TypeScript
|
|
21
|
+
TypeScript config requires:
|
|
36
22
|
|
|
37
23
|
```json
|
|
38
24
|
{
|
|
@@ -43,190 +29,78 @@ TypeScript requirements:
|
|
|
43
29
|
}
|
|
44
30
|
```
|
|
45
31
|
|
|
46
|
-
Entry point must import metadata:
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
import "reflect-metadata";
|
|
50
|
-
```
|
|
51
|
-
|
|
52
32
|
## Quick Start
|
|
53
33
|
|
|
54
34
|
```ts
|
|
55
35
|
import "reflect-metadata";
|
|
56
|
-
|
|
57
36
|
import { Application } from "@smounters/imperium/core";
|
|
58
37
|
import { Body, HttpController, Injectable, Module, Post } from "@smounters/imperium/decorators";
|
|
59
38
|
|
|
60
39
|
@Injectable()
|
|
61
|
-
class
|
|
62
|
-
|
|
63
|
-
return {
|
|
40
|
+
class GreetService {
|
|
41
|
+
greet(name: string) {
|
|
42
|
+
return { message: `Hello, ${name}` };
|
|
64
43
|
}
|
|
65
44
|
}
|
|
66
45
|
|
|
67
|
-
@HttpController("/
|
|
68
|
-
class
|
|
69
|
-
constructor(private readonly
|
|
46
|
+
@HttpController("/api")
|
|
47
|
+
class ApiController {
|
|
48
|
+
constructor(private readonly greetService: GreetService) {}
|
|
70
49
|
|
|
71
|
-
@Post("/
|
|
72
|
-
|
|
73
|
-
return this.
|
|
50
|
+
@Post("/greet")
|
|
51
|
+
greet(@Body("name") name: string) {
|
|
52
|
+
return this.greetService.greet(name);
|
|
74
53
|
}
|
|
75
54
|
}
|
|
76
55
|
|
|
77
56
|
@Module({
|
|
78
|
-
providers: [
|
|
79
|
-
httpControllers: [
|
|
57
|
+
providers: [GreetService],
|
|
58
|
+
httpControllers: [ApiController],
|
|
80
59
|
})
|
|
81
60
|
class AppModule {}
|
|
82
61
|
|
|
83
|
-
|
|
84
|
-
host: "0.0.0.0",
|
|
85
|
-
accessLogs: true,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
await app.start({ port: 3000 });
|
|
62
|
+
await new Application(AppModule).start({ port: 3000 });
|
|
89
63
|
```
|
|
90
64
|
|
|
91
|
-
##
|
|
65
|
+
## WebSocket
|
|
92
66
|
|
|
93
67
|
```ts
|
|
94
|
-
import {
|
|
95
|
-
import {
|
|
96
|
-
import {
|
|
97
|
-
|
|
98
|
-
const app = new Application(AppModule, {
|
|
99
|
-
host: "0.0.0.0",
|
|
100
|
-
accessLogs: true,
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
app.configureConfig(appConfigSchema, process.env);
|
|
68
|
+
import { WsGateway, WsHandler, WsConnection, WsMessage } from "@smounters/imperium/decorators";
|
|
69
|
+
import type { WsGatewayLifecycle } from "@smounters/imperium/ws";
|
|
70
|
+
import type { WebSocket } from "@fastify/websocket";
|
|
104
71
|
|
|
105
|
-
|
|
72
|
+
@WsGateway("/ws")
|
|
73
|
+
class EventsGateway implements WsGatewayLifecycle {
|
|
74
|
+
private clients = new Set<WebSocket>();
|
|
106
75
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
minLevel: 3,
|
|
110
|
-
});
|
|
76
|
+
onConnection(socket: WebSocket) { this.clients.add(socket); }
|
|
77
|
+
onDisconnect(socket: WebSocket) { this.clients.delete(socket); }
|
|
111
78
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
app.resolve(LoggerService).info({ event: "app.started", port: config.APP_PORT });
|
|
79
|
+
@WsHandler("ping")
|
|
80
|
+
onPing(@WsConnection() ws: WebSocket) {
|
|
81
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
118
84
|
```
|
|
119
85
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
import { appConfigSchema } from "@smounters/imperium/validation";
|
|
124
|
-
import { z } from "zod";
|
|
86
|
+
Requires optional peer dependency: `pnpm add @fastify/websocket`
|
|
125
87
|
|
|
126
|
-
|
|
127
|
-
REDIS_URL: z.url(),
|
|
128
|
-
});
|
|
129
|
-
```
|
|
88
|
+
## Import Paths
|
|
130
89
|
|
|
131
|
-
|
|
90
|
+
No root import. Use subpaths:
|
|
132
91
|
|
|
133
92
|
```ts
|
|
134
|
-
import {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
@
|
|
139
|
-
|
|
140
|
-
{ provide: AML_RULES, multi: true, useClass: SanctionsRule },
|
|
141
|
-
{ provide: AML_RULES, multi: true, useClass: MixerRule },
|
|
142
|
-
{ provide: AML_RULES, multi: true, useClass: FreshAddressRule },
|
|
143
|
-
],
|
|
144
|
-
exports: [AML_RULES],
|
|
145
|
-
})
|
|
146
|
-
class AmlModule {}
|
|
147
|
-
|
|
148
|
-
@Injectable()
|
|
149
|
-
class AmlEngine {
|
|
150
|
-
constructor(@InjectAll(AML_RULES) private readonly rules: AmlRule[]) {}
|
|
151
|
-
}
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Manual resolution is also available:
|
|
155
|
-
|
|
156
|
-
```ts
|
|
157
|
-
const rules = app.resolveAll<AmlRule>(AML_RULES);
|
|
93
|
+
import { Application } from "@smounters/imperium/core";
|
|
94
|
+
import { Module, Injectable, HttpController, Get } from "@smounters/imperium/decorators";
|
|
95
|
+
import { ConfigService, LoggerService } from "@smounters/imperium/services";
|
|
96
|
+
import { ZodPipe } from "@smounters/imperium/pipes";
|
|
97
|
+
import { appConfigSchema } from "@smounters/imperium/validation";
|
|
98
|
+
import { registerWsGateways } from "@smounters/imperium/ws";
|
|
158
99
|
```
|
|
159
100
|
|
|
160
|
-
## HTTP and RPC
|
|
161
|
-
|
|
162
|
-
Use decorators from `@smounters/imperium/decorators`:
|
|
163
|
-
|
|
164
|
-
- HTTP: `HttpController`, `Get`, `Post`, `Put`, `Patch`, `Delete`, `Body`, `Query`, `Param`, `Header`, `Req`, `Res`
|
|
165
|
-
- RPC: `RpcService`, `RpcMethod`, `RpcData`, `RpcContext`, `RpcHeaders`, `RpcHeader`
|
|
166
|
-
|
|
167
|
-
Imperium auto-detects registered HTTP/RPC handlers and serves both protocols from one server.
|
|
168
|
-
|
|
169
|
-
## Services
|
|
170
|
-
|
|
171
|
-
From `@smounters/imperium/services`:
|
|
172
|
-
|
|
173
|
-
- `ConfigService`
|
|
174
|
-
- `LoggerService`
|
|
175
|
-
|
|
176
|
-
## Pipes and Validation
|
|
177
|
-
|
|
178
|
-
- `ZodPipe` from `@smounters/imperium/pipes`
|
|
179
|
-
- validation helpers from `@smounters/imperium/validation`
|
|
180
|
-
- `appConfigSchema`
|
|
181
|
-
- `AppConfig`
|
|
182
|
-
- `booleanSchema`
|
|
183
|
-
- `numberSchema`
|
|
184
|
-
- `nativeEnumSchema`
|
|
185
|
-
- `stringArraySchema`
|
|
186
|
-
- `enumArraySchema`
|
|
187
|
-
|
|
188
|
-
## Error Classes
|
|
189
|
-
|
|
190
|
-
From `@smounters/imperium/core`:
|
|
191
|
-
|
|
192
|
-
- `HttpException`
|
|
193
|
-
- `BadRequestException`
|
|
194
|
-
- `UnauthorizedException`
|
|
195
|
-
- `ForbiddenException`
|
|
196
|
-
- `NotFoundException`
|
|
197
|
-
- `InternalServerErrorException`
|
|
198
|
-
|
|
199
101
|
## Documentation
|
|
200
102
|
|
|
201
|
-
Full
|
|
202
|
-
|
|
203
|
-
- `docs`
|
|
204
|
-
|
|
205
|
-
Local docs commands:
|
|
206
|
-
|
|
207
|
-
```bash
|
|
208
|
-
pnpm run docs:dev
|
|
209
|
-
pnpm run docs:build
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
GitHub Pages deployment is configured via `.github/workflows/publish.yml`.
|
|
213
|
-
|
|
214
|
-
## Publish to npm
|
|
215
|
-
|
|
216
|
-
```bash
|
|
217
|
-
pnpm install
|
|
218
|
-
pnpm run typecheck
|
|
219
|
-
pnpm run build
|
|
220
|
-
pnpm publish --access public --no-git-checks
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Automated npm publishing workflow:
|
|
224
|
-
|
|
225
|
-
- `.github/workflows/publish.yml`
|
|
226
|
-
|
|
227
|
-
Required secret:
|
|
228
|
-
|
|
229
|
-
- `NPM_TOKEN`
|
|
103
|
+
Full guide and API reference: **[smounters.github.io/imperium](https://smounters.github.io/imperium/)**
|
|
230
104
|
|
|
231
105
|
## License
|
|
232
106
|
|
package/dist/core/container.d.ts
CHANGED
|
@@ -11,8 +11,10 @@ export declare class AppContainer {
|
|
|
11
11
|
private readonly loadingModules;
|
|
12
12
|
private readonly rpcControllerSet;
|
|
13
13
|
private readonly httpControllerSet;
|
|
14
|
+
private readonly wsGatewaySet;
|
|
14
15
|
private readonly rpcControllerModules;
|
|
15
16
|
private readonly httpControllerModules;
|
|
17
|
+
private readonly wsGatewayModules;
|
|
16
18
|
private readonly lifecycleTargets;
|
|
17
19
|
private readonly requestScopeOwners;
|
|
18
20
|
private readonly requestScopeLinkedScopes;
|
|
@@ -20,6 +22,7 @@ export declare class AppContainer {
|
|
|
20
22
|
private rootModuleRef;
|
|
21
23
|
private controllers;
|
|
22
24
|
private httpControllers;
|
|
25
|
+
private wsGateways;
|
|
23
26
|
private lifecycleInstances;
|
|
24
27
|
private initialized;
|
|
25
28
|
private closed;
|
|
@@ -65,6 +68,7 @@ export declare class AppContainer {
|
|
|
65
68
|
createRequestScope(controller?: Constructor): DependencyContainer;
|
|
66
69
|
getControllers(): Constructor[];
|
|
67
70
|
getHttpControllers(): Constructor[];
|
|
71
|
+
getWsGateways(): Constructor[];
|
|
68
72
|
setGlobalGuards(guards: GuardLike[]): void;
|
|
69
73
|
getGlobalGuards(): GuardLike[];
|
|
70
74
|
setGlobalInterceptors(inters: InterceptorLike[]): void;
|
package/dist/core/container.js
CHANGED
|
@@ -2,6 +2,7 @@ import "reflect-metadata";
|
|
|
2
2
|
import { container, Lifecycle } from "tsyringe";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
import { MODULE_KEY } from "../decorators/di.decorators";
|
|
5
|
+
import { WS_GATEWAY_KEY } from "../decorators/ws.decorators";
|
|
5
6
|
import { ConfigService, LoggerService } from "../services";
|
|
6
7
|
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from "./app-tokens";
|
|
7
8
|
import { CONFIG_TOKEN } from "./config";
|
|
@@ -121,8 +122,10 @@ export class AppContainer {
|
|
|
121
122
|
this.loadingModules = new Set();
|
|
122
123
|
this.rpcControllerSet = new Set();
|
|
123
124
|
this.httpControllerSet = new Set();
|
|
125
|
+
this.wsGatewaySet = new Set();
|
|
124
126
|
this.rpcControllerModules = new Map();
|
|
125
127
|
this.httpControllerModules = new Map();
|
|
128
|
+
this.wsGatewayModules = new Map();
|
|
126
129
|
this.lifecycleTargets = [];
|
|
127
130
|
this.requestScopeOwners = new WeakMap();
|
|
128
131
|
this.requestScopeLinkedScopes = new WeakMap();
|
|
@@ -130,6 +133,7 @@ export class AppContainer {
|
|
|
130
133
|
this.rootModuleRef = null;
|
|
131
134
|
this.controllers = [];
|
|
132
135
|
this.httpControllers = [];
|
|
136
|
+
this.wsGateways = [];
|
|
133
137
|
this.lifecycleInstances = null;
|
|
134
138
|
this.initialized = false;
|
|
135
139
|
this.closed = false;
|
|
@@ -225,7 +229,7 @@ export class AppContainer {
|
|
|
225
229
|
return provider.useValue;
|
|
226
230
|
}
|
|
227
231
|
if ("useFactory" in provider) {
|
|
228
|
-
return provider.useFactory(moduleRef.container);
|
|
232
|
+
return (provider).useFactory(moduleRef.container);
|
|
229
233
|
}
|
|
230
234
|
if ("useExisting" in provider) {
|
|
231
235
|
return moduleRef.container.resolve(provider.useExisting);
|
|
@@ -277,7 +281,7 @@ export class AppContainer {
|
|
|
277
281
|
}
|
|
278
282
|
if ("useFactory" in provider) {
|
|
279
283
|
moduleRef.container.register(provider.provide, {
|
|
280
|
-
useFactory: (dc) => provider.useFactory(dc),
|
|
284
|
+
useFactory: (dc) => (provider).useFactory(dc),
|
|
281
285
|
});
|
|
282
286
|
return;
|
|
283
287
|
}
|
|
@@ -324,7 +328,7 @@ export class AppContainer {
|
|
|
324
328
|
}
|
|
325
329
|
}
|
|
326
330
|
resolveControllerModule(controller) {
|
|
327
|
-
return this.httpControllerModules.get(controller) ?? this.rpcControllerModules.get(controller);
|
|
331
|
+
return this.httpControllerModules.get(controller) ?? this.rpcControllerModules.get(controller) ?? this.wsGatewayModules.get(controller);
|
|
328
332
|
}
|
|
329
333
|
getOrCreateLinkedScopes(originScope) {
|
|
330
334
|
const existing = this.requestScopeLinkedScopes.get(originScope);
|
|
@@ -433,6 +437,17 @@ export class AppContainer {
|
|
|
433
437
|
this.httpControllerModules.set(ctrl, moduleRef);
|
|
434
438
|
this.httpControllers.push(ctrl);
|
|
435
439
|
}
|
|
440
|
+
// Detect WS gateways among providers
|
|
441
|
+
for (const provider of meta.providers) {
|
|
442
|
+
const providerClass = typeof provider === "function" ? provider : undefined;
|
|
443
|
+
if (providerClass && Reflect.hasMetadata(WS_GATEWAY_KEY, providerClass)) {
|
|
444
|
+
if (!this.wsGatewaySet.has(providerClass)) {
|
|
445
|
+
this.wsGatewaySet.add(providerClass);
|
|
446
|
+
this.wsGatewayModules.set(providerClass, moduleRef);
|
|
447
|
+
this.wsGateways.push(providerClass);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
436
451
|
moduleRef.exportedTokens = this.resolveModuleExports(moduleRef);
|
|
437
452
|
if (moduleRef.meta.global) {
|
|
438
453
|
this.registerGlobalExports(moduleRef);
|
|
@@ -452,9 +467,7 @@ export class AppContainer {
|
|
|
452
467
|
}
|
|
453
468
|
loadModule(moduleImport) {
|
|
454
469
|
const moduleRef = this.loadModuleRef(moduleImport);
|
|
455
|
-
|
|
456
|
-
this.rootModuleRef = moduleRef;
|
|
457
|
-
}
|
|
470
|
+
this.rootModuleRef ??= moduleRef;
|
|
458
471
|
}
|
|
459
472
|
resolve(token) {
|
|
460
473
|
if (this.root.isRegistered(token, false)) {
|
|
@@ -660,6 +673,9 @@ export class AppContainer {
|
|
|
660
673
|
getHttpControllers() {
|
|
661
674
|
return [...this.httpControllers];
|
|
662
675
|
}
|
|
676
|
+
getWsGateways() {
|
|
677
|
+
return [...this.wsGateways];
|
|
678
|
+
}
|
|
663
679
|
setGlobalGuards(guards) {
|
|
664
680
|
this.globalGuards = [...guards];
|
|
665
681
|
}
|
package/dist/core/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { HTTP_ROUTES_KEY } from "../decorators/http.decorators";
|
|
|
6
6
|
import { RPC_METHODS_KEY, RPC_SERVICE_KEY } from "../decorators/rpc.decorators";
|
|
7
7
|
import { registerHttpRoutes } from "../http";
|
|
8
8
|
import { buildConnectRoutes } from "../rpc";
|
|
9
|
+
import { registerWsGateways } from "../ws";
|
|
9
10
|
import { AppContainer } from "./container";
|
|
10
11
|
function normalizePrefix(prefix) {
|
|
11
12
|
if (!prefix) {
|
|
@@ -151,8 +152,9 @@ export async function startServer(diOrOptions, options) {
|
|
|
151
152
|
const healthConfig = resolveHealth(health);
|
|
152
153
|
const http = hasRegisteredHttpHandlers(di);
|
|
153
154
|
const rpc = hasRegisteredRpcHandlers(di);
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
const ws = di.getWsGateways().length > 0;
|
|
156
|
+
if (!http && !rpc && !ws && !healthConfig.enabled) {
|
|
157
|
+
throw new Error("No handlers found for HTTP, RPC, or WebSocket. Register controllers in @Module().");
|
|
156
158
|
}
|
|
157
159
|
let server;
|
|
158
160
|
try {
|
|
@@ -177,9 +179,10 @@ export async function startServer(diOrOptions, options) {
|
|
|
177
179
|
const corsConfig = resolveCors(cors);
|
|
178
180
|
const effectiveHttpPrefix = mergePrefixes(prefix, httpPrefix);
|
|
179
181
|
const effectiveRpcPrefix = mergePrefixes(prefix, rpcPrefix);
|
|
180
|
-
server.addHook("onRequest",
|
|
182
|
+
server.addHook("onRequest", (req, _reply, done) => {
|
|
181
183
|
const request = req;
|
|
182
184
|
request.requestStartAt = Date.now();
|
|
185
|
+
done();
|
|
183
186
|
});
|
|
184
187
|
server.addHook("onResponse", async (req, reply) => {
|
|
185
188
|
const request = req;
|
|
@@ -254,6 +257,9 @@ export async function startServer(diOrOptions, options) {
|
|
|
254
257
|
routes: buildConnectRoutes(di),
|
|
255
258
|
});
|
|
256
259
|
}
|
|
260
|
+
if (ws) {
|
|
261
|
+
await registerWsGateways(server, di);
|
|
262
|
+
}
|
|
257
263
|
await server.listen({ port: listenPort, host });
|
|
258
264
|
return server;
|
|
259
265
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface HttpParamMeta {
|
|
|
11
11
|
source: HttpParamSource;
|
|
12
12
|
key?: string;
|
|
13
13
|
}
|
|
14
|
-
export type RpcParamSource = "data" | "context" | "headers" | "header";
|
|
14
|
+
export type RpcParamSource = "data" | "context" | "headers" | "header" | "abort_signal";
|
|
15
15
|
export interface RpcParamMeta {
|
|
16
16
|
index: number;
|
|
17
17
|
source: RpcParamSource;
|
|
@@ -5,4 +5,5 @@ export { Body, Delete, Get, Header, HttpController, Param, Patch, Post, Put, Que
|
|
|
5
5
|
export { UseInterceptors } from "./interceptors.decorators";
|
|
6
6
|
export { SetMetadata } from "./metadata.decorators";
|
|
7
7
|
export { UsePipes } from "./pipes.decorators";
|
|
8
|
-
export { RpcContext, RpcData, RpcHeader, RpcHeaders, RpcMethod, RpcService } from "./rpc.decorators";
|
|
8
|
+
export { RpcAbortSignal, RpcContext, RpcData, RpcHeader, RpcHeaders, RpcMethod, RpcService } from "./rpc.decorators";
|
|
9
|
+
export { WsConnection, WsGateway, WsHandler, WsMessage, WsRequest } from "./ws.decorators";
|
package/dist/decorators/index.js
CHANGED
|
@@ -5,4 +5,5 @@ export { Body, Delete, Get, Header, HttpController, Param, Patch, Post, Put, Que
|
|
|
5
5
|
export { UseInterceptors } from "./interceptors.decorators";
|
|
6
6
|
export { SetMetadata } from "./metadata.decorators";
|
|
7
7
|
export { UsePipes } from "./pipes.decorators";
|
|
8
|
-
export { RpcContext, RpcData, RpcHeader, RpcHeaders, RpcMethod, RpcService } from "./rpc.decorators";
|
|
8
|
+
export { RpcAbortSignal, RpcContext, RpcData, RpcHeader, RpcHeaders, RpcMethod, RpcService } from "./rpc.decorators";
|
|
9
|
+
export { WsConnection, WsGateway, WsHandler, WsMessage, WsRequest } from "./ws.decorators";
|
|
@@ -9,3 +9,4 @@ export declare const RpcData: (key?: string) => ParameterDecorator;
|
|
|
9
9
|
export declare const RpcContext: () => ParameterDecorator;
|
|
10
10
|
export declare const RpcHeaders: () => ParameterDecorator;
|
|
11
11
|
export declare const RpcHeader: (key: string) => ParameterDecorator;
|
|
12
|
+
export declare const RpcAbortSignal: () => ParameterDecorator;
|
|
@@ -48,3 +48,4 @@ export const RpcData = (key) => rpcParamDecorator("data", key);
|
|
|
48
48
|
export const RpcContext = () => rpcParamDecorator("context");
|
|
49
49
|
export const RpcHeaders = () => rpcParamDecorator("headers");
|
|
50
50
|
export const RpcHeader = (key) => rpcParamDecorator("header", key);
|
|
51
|
+
export const RpcAbortSignal = () => rpcParamDecorator("abort_signal");
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
export declare const WS_GATEWAY_KEY: unique symbol;
|
|
3
|
+
export declare const WS_HANDLERS_KEY: unique symbol;
|
|
4
|
+
export declare const WS_PARAMS_KEY: unique symbol;
|
|
5
|
+
export declare function WsGateway(path?: string): ClassDecorator;
|
|
6
|
+
export declare function WsHandler(messageType: string): MethodDecorator;
|
|
7
|
+
export declare const WsConnection: () => ParameterDecorator;
|
|
8
|
+
export declare const WsMessage: () => ParameterDecorator;
|
|
9
|
+
export declare const WsRequest: () => ParameterDecorator;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { Injectable } from "./di.decorators";
|
|
3
|
+
export const WS_GATEWAY_KEY = Symbol("ws:gateway");
|
|
4
|
+
export const WS_HANDLERS_KEY = Symbol("ws:handlers");
|
|
5
|
+
export const WS_PARAMS_KEY = Symbol("ws:params");
|
|
6
|
+
export function WsGateway(path = "/ws") {
|
|
7
|
+
return (target) => {
|
|
8
|
+
Injectable()(target);
|
|
9
|
+
const meta = { path };
|
|
10
|
+
Reflect.defineMetadata(WS_GATEWAY_KEY, meta, target);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function WsHandler(messageType) {
|
|
14
|
+
return (target, propertyKey) => {
|
|
15
|
+
const handlers = Reflect.getMetadata(WS_HANDLERS_KEY, target.constructor) ?? [];
|
|
16
|
+
handlers.push({ messageType, handlerName: propertyKey });
|
|
17
|
+
Reflect.defineMetadata(WS_HANDLERS_KEY, handlers, target.constructor);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function getMetaTarget(target, propertyKey) {
|
|
21
|
+
if (propertyKey === undefined) {
|
|
22
|
+
return target;
|
|
23
|
+
}
|
|
24
|
+
const value = target[propertyKey];
|
|
25
|
+
if (typeof value !== "object" && typeof value !== "function") {
|
|
26
|
+
throw new Error("Decorator target is not an object");
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function setParamMeta(target, propertyKey, meta) {
|
|
31
|
+
const metaTarget = getMetaTarget(target, propertyKey);
|
|
32
|
+
const existing = Reflect.getMetadata(WS_PARAMS_KEY, metaTarget);
|
|
33
|
+
const next = existing ? [...existing, meta] : [meta];
|
|
34
|
+
Reflect.defineMetadata(WS_PARAMS_KEY, next, metaTarget);
|
|
35
|
+
}
|
|
36
|
+
function wsParamDecorator(source) {
|
|
37
|
+
return (target, propertyKey, index) => {
|
|
38
|
+
setParamMeta(target, propertyKey ?? undefined, { index, source });
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export const WsConnection = () => wsParamDecorator("connection");
|
|
42
|
+
export const WsMessage = () => wsParamDecorator("message");
|
|
43
|
+
export const WsRequest = () => wsParamDecorator("request");
|
package/dist/http/adapter.js
CHANGED
|
@@ -135,6 +135,11 @@ export function createHttpHandler(app, controller, methodName) {
|
|
|
135
135
|
getData: () => undefined,
|
|
136
136
|
getContext: () => undefined,
|
|
137
137
|
}),
|
|
138
|
+
switchToWs: () => ({
|
|
139
|
+
getSocket: () => undefined,
|
|
140
|
+
getRequest: () => undefined,
|
|
141
|
+
getMessage: () => undefined,
|
|
142
|
+
}),
|
|
138
143
|
};
|
|
139
144
|
try {
|
|
140
145
|
const instance = requestScope.resolve(controller);
|
|
@@ -159,7 +164,7 @@ export function createHttpHandler(app, controller, methodName) {
|
|
|
159
164
|
transformedArgs[index] = value;
|
|
160
165
|
}
|
|
161
166
|
const interceptors = collectInterceptorsForHttp(controller, methodName, app.getGlobalInterceptors()).map((interceptorLike) => resolveEnhancer(requestScope, interceptorLike));
|
|
162
|
-
const controllerHandler =
|
|
167
|
+
const controllerHandler = instance[methodName].bind(instance);
|
|
163
168
|
let idx = -1;
|
|
164
169
|
const next = async () => {
|
|
165
170
|
idx++;
|
package/dist/rpc/adapter.js
CHANGED
|
@@ -50,6 +50,9 @@ function buildRpcArgs(payload, context, handler) {
|
|
|
50
50
|
case "header":
|
|
51
51
|
args[meta.index] = meta.key ? context.requestHeader.get(meta.key) : undefined;
|
|
52
52
|
break;
|
|
53
|
+
case "abort_signal":
|
|
54
|
+
args[meta.index] = context.signal;
|
|
55
|
+
break;
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
return args;
|
|
@@ -124,6 +127,11 @@ export function createRpcHandler(app, controller, methodName) {
|
|
|
124
127
|
getData: () => req,
|
|
125
128
|
getContext: () => context,
|
|
126
129
|
}),
|
|
130
|
+
switchToWs: () => ({
|
|
131
|
+
getSocket: () => undefined,
|
|
132
|
+
getRequest: () => undefined,
|
|
133
|
+
getMessage: () => undefined,
|
|
134
|
+
}),
|
|
127
135
|
};
|
|
128
136
|
try {
|
|
129
137
|
const instance = scope.resolve(controller);
|
package/dist/rpc/index.d.ts
CHANGED
package/dist/rpc/index.js
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import "reflect-metadata";
|
|
2
2
|
import { RPC_METHODS_KEY, RPC_SERVICE_KEY } from "../decorators/rpc.decorators";
|
|
3
3
|
import { createRpcHandler } from "./adapter";
|
|
4
|
-
|
|
5
|
-
if (method.methodKind === "unary") {
|
|
6
|
-
return;
|
|
7
|
-
}
|
|
8
|
-
const serviceName = method.parent.name;
|
|
9
|
-
const rpcName = method.name;
|
|
10
|
-
throw new Error(`Streaming RPC methods are not supported: ${serviceName}.${rpcName} (${method.methodKind}) ` +
|
|
11
|
-
`at ${controller.name}.${handlerName}. Only unary methods are supported.`);
|
|
12
|
-
}
|
|
4
|
+
import { createStreamingRpcHandler } from "./streaming-adapter";
|
|
13
5
|
export function buildConnectRoutes(di) {
|
|
14
6
|
return (router) => {
|
|
15
7
|
for (const ctrl of di.getControllers()) {
|
|
@@ -20,8 +12,18 @@ export function buildConnectRoutes(di) {
|
|
|
20
12
|
}
|
|
21
13
|
const methods = Reflect.getMetadata(RPC_METHODS_KEY, controller) ?? [];
|
|
22
14
|
for (const { method, handlerName } of methods) {
|
|
23
|
-
|
|
24
|
-
|
|
15
|
+
if (method.methodKind === "unary") {
|
|
16
|
+
router.rpc(method, createRpcHandler(di, controller, handlerName));
|
|
17
|
+
}
|
|
18
|
+
else if (method.methodKind === "server_streaming") {
|
|
19
|
+
router.rpc(method, createStreamingRpcHandler(di, controller, handlerName));
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const serviceName = method.parent.name;
|
|
23
|
+
const rpcName = method.name;
|
|
24
|
+
throw new Error(`Client streaming and bidi streaming are not supported: ${serviceName}.${rpcName} (${method.methodKind}) ` +
|
|
25
|
+
`at ${controller.name}.${handlerName}. Use unary or server_streaming.`);
|
|
26
|
+
}
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { HandlerContext } from "@connectrpc/connect";
|
|
2
|
+
import "reflect-metadata";
|
|
3
|
+
import type { AppContainer } from "../core/container";
|
|
4
|
+
import type { Constructor } from "../types";
|
|
5
|
+
export declare function createStreamingRpcHandler<TController extends Record<string, unknown>>(app: AppContainer, controller: Constructor<TController>, methodName: keyof TController & string): (req: unknown, context: HandlerContext) => AsyncIterable<unknown>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { ForbiddenException, toConnectError } from "../core/errors";
|
|
3
|
+
import { CATCH_EXCEPTIONS_KEY } from "../decorators/filters.decorators";
|
|
4
|
+
import { RPC_PARAMS_KEY } from "../decorators/rpc.decorators";
|
|
5
|
+
import { LoggerService } from "../services";
|
|
6
|
+
import { collectFiltersForRpc, collectGuardsForRpc, collectPipesForRpc } from "./utils";
|
|
7
|
+
function logRpcError(app, scope, details, error) {
|
|
8
|
+
try {
|
|
9
|
+
scope.resolve(LoggerService).error(details, error);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// fallback below
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
app.getLogger().error(details, error);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// final fallback
|
|
21
|
+
}
|
|
22
|
+
console.error("[imperium] rpc_streaming_error", details, error);
|
|
23
|
+
}
|
|
24
|
+
function getPayloadValue(payload, key) {
|
|
25
|
+
if (!key) {
|
|
26
|
+
return payload;
|
|
27
|
+
}
|
|
28
|
+
if (!payload || typeof payload !== "object") {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return payload[key];
|
|
32
|
+
}
|
|
33
|
+
function buildRpcArgs(payload, context, handler) {
|
|
34
|
+
const metas = Reflect.getMetadata(RPC_PARAMS_KEY, handler) ?? [];
|
|
35
|
+
if (!metas.length) {
|
|
36
|
+
return [payload];
|
|
37
|
+
}
|
|
38
|
+
const args = [];
|
|
39
|
+
for (const meta of metas) {
|
|
40
|
+
switch (meta.source) {
|
|
41
|
+
case "data":
|
|
42
|
+
args[meta.index] = getPayloadValue(payload, meta.key);
|
|
43
|
+
break;
|
|
44
|
+
case "context":
|
|
45
|
+
args[meta.index] = context;
|
|
46
|
+
break;
|
|
47
|
+
case "headers":
|
|
48
|
+
args[meta.index] = context.requestHeader;
|
|
49
|
+
break;
|
|
50
|
+
case "header":
|
|
51
|
+
args[meta.index] = meta.key ? context.requestHeader.get(meta.key) : undefined;
|
|
52
|
+
break;
|
|
53
|
+
case "abort_signal":
|
|
54
|
+
args[meta.index] = context.signal;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
function resolveEnhancer(scope, enhancer) {
|
|
61
|
+
if (typeof enhancer === "function") {
|
|
62
|
+
return scope.resolve(enhancer);
|
|
63
|
+
}
|
|
64
|
+
return enhancer;
|
|
65
|
+
}
|
|
66
|
+
function canHandleException(error, filter) {
|
|
67
|
+
const exceptions = Reflect.getMetadata(CATCH_EXCEPTIONS_KEY, filter.constructor);
|
|
68
|
+
if (!exceptions || exceptions.length === 0) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (!(error instanceof Error)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return exceptions.some((ExceptionType) => error instanceof ExceptionType);
|
|
75
|
+
}
|
|
76
|
+
async function runRpcFilters(error, filters, ctx) {
|
|
77
|
+
let currentError = error;
|
|
78
|
+
for (const filter of filters) {
|
|
79
|
+
if (!canHandleException(currentError, filter)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const result = await filter.catch(currentError, ctx);
|
|
84
|
+
if (result !== undefined) {
|
|
85
|
+
return {
|
|
86
|
+
handled: true,
|
|
87
|
+
error: currentError,
|
|
88
|
+
result,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (nextError) {
|
|
93
|
+
currentError = nextError;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
handled: false,
|
|
98
|
+
error: currentError,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function createStreamingRpcHandler(app, controller, methodName) {
|
|
102
|
+
return async function* (req, context) {
|
|
103
|
+
const scope = app.createRequestScope(controller);
|
|
104
|
+
const handler = controller.prototype[methodName];
|
|
105
|
+
const ctx = {
|
|
106
|
+
type: "rpc",
|
|
107
|
+
method: methodName,
|
|
108
|
+
headers: context.requestHeader,
|
|
109
|
+
rpc: {
|
|
110
|
+
data: req,
|
|
111
|
+
context,
|
|
112
|
+
},
|
|
113
|
+
controller,
|
|
114
|
+
handler,
|
|
115
|
+
getType: () => "rpc",
|
|
116
|
+
getClass: () => controller,
|
|
117
|
+
getHandler: () => handler,
|
|
118
|
+
switchToHttp: () => ({
|
|
119
|
+
getRequest: () => undefined,
|
|
120
|
+
getResponse: () => undefined,
|
|
121
|
+
}),
|
|
122
|
+
switchToRpc: () => ({
|
|
123
|
+
getData: () => req,
|
|
124
|
+
getContext: () => context,
|
|
125
|
+
}),
|
|
126
|
+
switchToWs: () => ({
|
|
127
|
+
getSocket: () => undefined,
|
|
128
|
+
getRequest: () => undefined,
|
|
129
|
+
getMessage: () => undefined,
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
await app.runInRequestScope(scope, async () => {
|
|
134
|
+
// Resolve instance inside request scope
|
|
135
|
+
});
|
|
136
|
+
// We need to run guards/pipes inside the scope, then yield outside of runInRequestScope
|
|
137
|
+
// because async generators cannot be fully consumed inside a single runInRequestScope call.
|
|
138
|
+
// Instead, we set up the scope context and run guards/pipes, then yield from the generator.
|
|
139
|
+
const instance = scope.resolve(controller);
|
|
140
|
+
const guards = collectGuardsForRpc(controller, methodName, app.getGlobalGuards()).map((guardLike) => resolveEnhancer(scope, guardLike));
|
|
141
|
+
for (const guard of guards) {
|
|
142
|
+
const ok = await guard.canActivate(ctx);
|
|
143
|
+
if (!ok) {
|
|
144
|
+
throw new ForbiddenException();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
let payload = req;
|
|
148
|
+
const pipes = collectPipesForRpc(controller, methodName, app.getGlobalPipes()).map((pipeLike) => resolveEnhancer(scope, pipeLike));
|
|
149
|
+
for (const pipe of pipes) {
|
|
150
|
+
payload = await pipe.transform(payload, ctx);
|
|
151
|
+
}
|
|
152
|
+
const args = buildRpcArgs(payload, context, handler);
|
|
153
|
+
const generator = instance[methodName].apply(instance, args);
|
|
154
|
+
yield* generator;
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
logRpcError(app, scope, {
|
|
158
|
+
type: "rpc_streaming_error",
|
|
159
|
+
controller: controller.name,
|
|
160
|
+
handler: methodName,
|
|
161
|
+
procedure: context.method.name,
|
|
162
|
+
}, error);
|
|
163
|
+
const filters = collectFiltersForRpc(controller, methodName, app.getGlobalFilters()).map((filterLike) => resolveEnhancer(scope, filterLike));
|
|
164
|
+
const filtered = await runRpcFilters(error, filters, ctx);
|
|
165
|
+
if (filtered.handled) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
throw toConnectError(filtered.error, {
|
|
169
|
+
exposeInternalErrors: app.shouldExposeInternalErrors(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
try {
|
|
174
|
+
await app.disposeRequestScope(scope);
|
|
175
|
+
}
|
|
176
|
+
catch (disposeError) {
|
|
177
|
+
logRpcError(app, scope, {
|
|
178
|
+
type: "rpc_streaming_scope_dispose_error",
|
|
179
|
+
controller: controller.name,
|
|
180
|
+
handler: methodName,
|
|
181
|
+
procedure: context.method.name,
|
|
182
|
+
}, disposeError);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ISettingsParam } from "tslog";
|
|
|
3
3
|
import type { DependencyContainer, RegistrationOptions, InjectionToken as TsyringeInjectionToken } from "tsyringe";
|
|
4
4
|
import type { ZodType } from "zod";
|
|
5
5
|
export type Constructor<T = unknown> = new (...args: any[]) => T;
|
|
6
|
-
export type ContextType = "http" | "rpc";
|
|
6
|
+
export type ContextType = "http" | "rpc" | "ws";
|
|
7
7
|
export type LoggerOptions = ISettingsParam<Record<string, unknown>>;
|
|
8
8
|
export type InjectionToken<T = unknown> = TsyringeInjectionToken<T>;
|
|
9
9
|
export type MetadataKey = string | symbol;
|
|
@@ -39,8 +39,7 @@ interface BaseModuleMeta {
|
|
|
39
39
|
exports?: InjectionToken[];
|
|
40
40
|
global?: boolean;
|
|
41
41
|
}
|
|
42
|
-
export
|
|
43
|
-
}
|
|
42
|
+
export type ModuleMeta = BaseModuleMeta;
|
|
44
43
|
export interface DynamicModule extends BaseModuleMeta {
|
|
45
44
|
module: Constructor;
|
|
46
45
|
id?: string;
|
|
@@ -69,6 +68,11 @@ export interface RpcArgumentsHost {
|
|
|
69
68
|
getData<T = unknown>(): T | undefined;
|
|
70
69
|
getContext<T = unknown>(): T | undefined;
|
|
71
70
|
}
|
|
71
|
+
export interface WsArgumentsHost {
|
|
72
|
+
getSocket(): unknown;
|
|
73
|
+
getRequest(): FastifyRequest | undefined;
|
|
74
|
+
getMessage(): unknown;
|
|
75
|
+
}
|
|
72
76
|
export interface BaseContext {
|
|
73
77
|
type: ContextType;
|
|
74
78
|
method: string;
|
|
@@ -82,6 +86,11 @@ export interface BaseContext {
|
|
|
82
86
|
data: unknown;
|
|
83
87
|
context: unknown;
|
|
84
88
|
};
|
|
89
|
+
ws?: {
|
|
90
|
+
socket: unknown;
|
|
91
|
+
request: FastifyRequest;
|
|
92
|
+
message?: unknown;
|
|
93
|
+
};
|
|
85
94
|
controller: Constructor;
|
|
86
95
|
handler: Function;
|
|
87
96
|
getType(): ContextType;
|
|
@@ -89,6 +98,7 @@ export interface BaseContext {
|
|
|
89
98
|
getHandler(): Function;
|
|
90
99
|
switchToHttp(): HttpArgumentsHost;
|
|
91
100
|
switchToRpc(): RpcArgumentsHost;
|
|
101
|
+
switchToWs(): WsArgumentsHost;
|
|
92
102
|
}
|
|
93
103
|
export interface Guard {
|
|
94
104
|
canActivate(ctx: BaseContext): Promise<boolean> | boolean;
|
|
@@ -104,7 +114,7 @@ export interface PipeTransform<TIn = unknown, TOut = unknown> {
|
|
|
104
114
|
}
|
|
105
115
|
export type PipeLike = Constructor<PipeTransform> | PipeTransform;
|
|
106
116
|
export interface ExceptionFilter<T = unknown> {
|
|
107
|
-
catch(exception: T, ctx: BaseContext):
|
|
117
|
+
catch(exception: T, ctx: BaseContext): unknown;
|
|
108
118
|
}
|
|
109
119
|
export type ExceptionFilterLike = Constructor<ExceptionFilter> | ExceptionFilter;
|
|
110
120
|
export interface ConfigServiceOptions<TSchema extends ZodType = ZodType> {
|
|
@@ -113,7 +123,7 @@ export interface ConfigServiceOptions<TSchema extends ZodType = ZodType> {
|
|
|
113
123
|
}
|
|
114
124
|
export interface CorsOptions {
|
|
115
125
|
enabled?: boolean;
|
|
116
|
-
origin?: string | boolean | RegExp |
|
|
126
|
+
origin?: string | boolean | RegExp | (string | boolean | RegExp)[];
|
|
117
127
|
credentials?: boolean;
|
|
118
128
|
exposedHeaders?: string | string[];
|
|
119
129
|
allowedHeaders?: string | string[];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FastifyRequest } from "fastify";
|
|
2
|
+
import type { WebSocket } from "@fastify/websocket";
|
|
3
|
+
import "reflect-metadata";
|
|
4
|
+
import type { AppContainer } from "../core/container";
|
|
5
|
+
import type { Constructor } from "../types";
|
|
6
|
+
export declare function handleWsConnection(app: AppContainer, gateway: Constructor, socket: WebSocket, request: FastifyRequest): void;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { GUARDS_KEY } from "../decorators/guards.decorators";
|
|
3
|
+
import { WS_HANDLERS_KEY, WS_PARAMS_KEY } from "../decorators/ws.decorators";
|
|
4
|
+
import { Reflector } from "../core/reflector";
|
|
5
|
+
import { LoggerService } from "../services";
|
|
6
|
+
const reflector = new Reflector();
|
|
7
|
+
function collectGuardsForWs(gateway, app) {
|
|
8
|
+
const classGuards = (reflector.get(GUARDS_KEY, gateway) ?? []);
|
|
9
|
+
return [...app.getGlobalGuards(), ...classGuards];
|
|
10
|
+
}
|
|
11
|
+
function resolveEnhancer(scope, enhancer) {
|
|
12
|
+
if (typeof enhancer === "function") {
|
|
13
|
+
return scope.resolve(enhancer);
|
|
14
|
+
}
|
|
15
|
+
return enhancer;
|
|
16
|
+
}
|
|
17
|
+
function buildWsArgs(socket, request, message, handler) {
|
|
18
|
+
const metas = Reflect.getMetadata(WS_PARAMS_KEY, handler) ?? [];
|
|
19
|
+
if (!metas.length) {
|
|
20
|
+
return [socket, message, request];
|
|
21
|
+
}
|
|
22
|
+
const args = [];
|
|
23
|
+
for (const meta of metas) {
|
|
24
|
+
switch (meta.source) {
|
|
25
|
+
case "connection":
|
|
26
|
+
args[meta.index] = socket;
|
|
27
|
+
break;
|
|
28
|
+
case "message":
|
|
29
|
+
args[meta.index] = message;
|
|
30
|
+
break;
|
|
31
|
+
case "request":
|
|
32
|
+
args[meta.index] = request;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
function buildWsContext(gateway, methodName, handler, socket, request, message) {
|
|
39
|
+
return {
|
|
40
|
+
type: "ws",
|
|
41
|
+
method: methodName,
|
|
42
|
+
controller: gateway,
|
|
43
|
+
handler,
|
|
44
|
+
ws: {
|
|
45
|
+
socket,
|
|
46
|
+
request,
|
|
47
|
+
message,
|
|
48
|
+
},
|
|
49
|
+
getType: () => "ws",
|
|
50
|
+
getClass: () => gateway,
|
|
51
|
+
getHandler: () => handler,
|
|
52
|
+
switchToHttp: () => ({
|
|
53
|
+
getRequest: () => undefined,
|
|
54
|
+
getResponse: () => undefined,
|
|
55
|
+
}),
|
|
56
|
+
switchToRpc: () => ({
|
|
57
|
+
getData: () => undefined,
|
|
58
|
+
getContext: () => undefined,
|
|
59
|
+
}),
|
|
60
|
+
switchToWs: () => ({
|
|
61
|
+
getSocket: () => socket,
|
|
62
|
+
getRequest: () => request,
|
|
63
|
+
getMessage: () => message,
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function logWsError(app, scope, details, error) {
|
|
68
|
+
try {
|
|
69
|
+
scope.resolve(LoggerService).error(details, error);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// fallback
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
app.getLogger().error(details, error);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// final fallback
|
|
81
|
+
}
|
|
82
|
+
console.error("[imperium] ws_error", details, error);
|
|
83
|
+
}
|
|
84
|
+
export function handleWsConnection(app, gateway, socket, request) {
|
|
85
|
+
const scope = app.createRequestScope(gateway);
|
|
86
|
+
const handlers = Reflect.getMetadata(WS_HANDLERS_KEY, gateway) ?? [];
|
|
87
|
+
const runAsync = async () => {
|
|
88
|
+
const instance = scope.resolve(gateway);
|
|
89
|
+
// Run guards at connection time
|
|
90
|
+
const guardLikes = collectGuardsForWs(gateway, app);
|
|
91
|
+
const guards = guardLikes.map((g) => resolveEnhancer(scope, g));
|
|
92
|
+
const ctx = buildWsContext(gateway, "onConnection", gateway, socket, request);
|
|
93
|
+
for (const guard of guards) {
|
|
94
|
+
const ok = await guard.canActivate(ctx);
|
|
95
|
+
if (!ok) {
|
|
96
|
+
socket.close(4403, "Forbidden");
|
|
97
|
+
await app.disposeRequestScope(scope);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Call onConnection lifecycle
|
|
102
|
+
if (typeof instance.onConnection === "function") {
|
|
103
|
+
await instance.onConnection(socket, request);
|
|
104
|
+
}
|
|
105
|
+
// Route messages to handlers
|
|
106
|
+
socket.on("message", (raw) => {
|
|
107
|
+
void (async () => {
|
|
108
|
+
try {
|
|
109
|
+
const msg = JSON.parse(String(raw));
|
|
110
|
+
const handler = handlers.find((h) => h.messageType === msg.type);
|
|
111
|
+
if (!handler) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const handlerFn = instance[handler.handlerName];
|
|
115
|
+
const args = buildWsArgs(socket, request, msg.data, handlerFn);
|
|
116
|
+
await handlerFn.apply(instance, args);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
logWsError(app, scope, {
|
|
120
|
+
type: "ws_message_error",
|
|
121
|
+
gateway: gateway.name,
|
|
122
|
+
}, error);
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
});
|
|
126
|
+
// Cleanup on disconnect
|
|
127
|
+
socket.on("close", () => {
|
|
128
|
+
void (async () => {
|
|
129
|
+
try {
|
|
130
|
+
if (typeof instance.onDisconnect === "function") {
|
|
131
|
+
await instance.onDisconnect(socket);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
logWsError(app, scope, {
|
|
136
|
+
type: "ws_disconnect_error",
|
|
137
|
+
gateway: gateway.name,
|
|
138
|
+
}, error);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
try {
|
|
142
|
+
await app.disposeRequestScope(scope);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
logWsError(app, scope, {
|
|
146
|
+
type: "ws_scope_dispose_error",
|
|
147
|
+
gateway: gateway.name,
|
|
148
|
+
}, error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
runAsync().catch((error) => {
|
|
155
|
+
logWsError(app, scope, {
|
|
156
|
+
type: "ws_connection_error",
|
|
157
|
+
gateway: gateway.name,
|
|
158
|
+
}, error);
|
|
159
|
+
try {
|
|
160
|
+
socket.close(4500, "Internal Server Error");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// socket may already be closed
|
|
164
|
+
}
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
166
|
+
app.disposeRequestScope(scope).catch(() => { });
|
|
167
|
+
});
|
|
168
|
+
}
|
package/dist/ws/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { WS_GATEWAY_KEY } from "../decorators/ws.decorators";
|
|
3
|
+
import { handleWsConnection } from "./adapter";
|
|
4
|
+
export async function registerWsGateways(server, app) {
|
|
5
|
+
const gateways = app.getWsGateways();
|
|
6
|
+
if (gateways.length === 0) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
// Dynamic import — @fastify/websocket is an optional peer dep
|
|
10
|
+
let fastifyWebsocket;
|
|
11
|
+
try {
|
|
12
|
+
fastifyWebsocket = await import("@fastify/websocket");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error("WebSocket gateways are registered but @fastify/websocket is not installed. " +
|
|
16
|
+
"Install it: pnpm add @fastify/websocket");
|
|
17
|
+
}
|
|
18
|
+
await server.register(fastifyWebsocket.default);
|
|
19
|
+
for (const gateway of gateways) {
|
|
20
|
+
const meta = Reflect.getMetadata(WS_GATEWAY_KEY, gateway);
|
|
21
|
+
server.get(meta.path, { websocket: true }, (socket, req) => {
|
|
22
|
+
handleWsConnection(app, gateway, socket, req);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FastifyRequest } from "fastify";
|
|
2
|
+
import type { WebSocket } from "@fastify/websocket";
|
|
3
|
+
export interface WsHandlerMeta {
|
|
4
|
+
messageType: string;
|
|
5
|
+
handlerName: string;
|
|
6
|
+
}
|
|
7
|
+
export interface WsGatewayMeta {
|
|
8
|
+
path: string;
|
|
9
|
+
}
|
|
10
|
+
export type WsParamSource = "connection" | "message" | "request";
|
|
11
|
+
export interface WsParamMeta {
|
|
12
|
+
index: number;
|
|
13
|
+
source: WsParamSource;
|
|
14
|
+
}
|
|
15
|
+
export interface WsGatewayLifecycle {
|
|
16
|
+
onConnection?(socket: WebSocket, req: FastifyRequest): void | Promise<void>;
|
|
17
|
+
onDisconnect?(socket: WebSocket): void | Promise<void>;
|
|
18
|
+
}
|
package/dist/ws/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smounters/imperium",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "NestJS-like modular DI container with unified HTTP + Connect RPC server for TypeScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"di",
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"./validation": {
|
|
32
32
|
"types": "./dist/validation/index.d.ts",
|
|
33
33
|
"import": "./dist/validation/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./ws": {
|
|
36
|
+
"types": "./dist/ws/index.d.ts",
|
|
37
|
+
"import": "./dist/ws/index.js"
|
|
34
38
|
}
|
|
35
39
|
},
|
|
36
40
|
"files": [
|
|
@@ -60,6 +64,8 @@
|
|
|
60
64
|
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
|
61
65
|
"format": "prettier . --write",
|
|
62
66
|
"format:check": "prettier . --check",
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"test:watch": "vitest",
|
|
63
69
|
"clean": "rm -rf dist",
|
|
64
70
|
"docs:dev": "pnpm exec vitepress dev docs",
|
|
65
71
|
"docs:build": "pnpm exec vitepress build docs",
|
|
@@ -71,30 +77,40 @@
|
|
|
71
77
|
"@connectrpc/connect": "^2.1.1",
|
|
72
78
|
"@connectrpc/connect-fastify": "^2.1.1",
|
|
73
79
|
"@fastify/cors": "^11.2.0",
|
|
80
|
+
"@fastify/websocket": "^11.0.0",
|
|
74
81
|
"fastify": "^5.7.4",
|
|
75
|
-
"tsyringe": "^4.10.0",
|
|
76
82
|
"reflect-metadata": "^0.2.2",
|
|
77
83
|
"tslog": "^4.10.2",
|
|
84
|
+
"tsyringe": "^4.10.0",
|
|
78
85
|
"typescript": "^5.9.3",
|
|
79
86
|
"zod": "^4.3.6"
|
|
80
87
|
},
|
|
88
|
+
"peerDependenciesMeta": {
|
|
89
|
+
"@fastify/websocket": {
|
|
90
|
+
"optional": true
|
|
91
|
+
}
|
|
92
|
+
},
|
|
81
93
|
"devDependencies": {
|
|
82
|
-
"@types/node": "^24.5.2",
|
|
83
|
-
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
84
|
-
"@typescript-eslint/parser": "^8.56.0",
|
|
85
94
|
"@connectrpc/connect": "^2.1.1",
|
|
86
95
|
"@connectrpc/connect-fastify": "^2.1.1",
|
|
96
|
+
"@eslint/js": "^10.0.1",
|
|
87
97
|
"@fastify/cors": "^11.2.0",
|
|
88
|
-
"
|
|
98
|
+
"@fastify/websocket": "^11.2.0",
|
|
99
|
+
"@types/node": "^25.5.0",
|
|
100
|
+
"@types/ws": "^8.18.1",
|
|
101
|
+
"eslint": "^10.1.0",
|
|
89
102
|
"eslint-config-prettier": "^10.1.8",
|
|
90
|
-
"fastify": "^5.
|
|
103
|
+
"fastify": "^5.8.4",
|
|
91
104
|
"prettier": "^3.8.1",
|
|
92
105
|
"reflect-metadata": "^0.2.2",
|
|
93
106
|
"tslog": "^4.10.2",
|
|
94
107
|
"tsyringe": "^4.10.0",
|
|
95
108
|
"typescript": "^5.9.3",
|
|
109
|
+
"typescript-eslint": "^8.57.2",
|
|
110
|
+
"vite": "^8.0.3",
|
|
96
111
|
"vitepress": "^1.6.4",
|
|
112
|
+
"vitest": "^4.1.2",
|
|
113
|
+
"ws": "^8.20.0",
|
|
97
114
|
"zod": "^4.3.6"
|
|
98
|
-
}
|
|
99
|
-
"dependencies": {}
|
|
115
|
+
}
|
|
100
116
|
}
|