@smounters/imperium 1.0.2 → 1.1.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/CHANGELOG.md +41 -0
- package/README.md +37 -4
- package/dist/core/container.d.ts +4 -0
- package/dist/core/container.js +19 -1
- package/dist/core/server.js +7 -2
- 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 +9 -1
- 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 +12 -1
- package/dist/ws/adapter.d.ts +6 -0
- package/dist/ws/adapter.js +167 -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 +33 -21
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
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
|
+
|
|
32
|
+
## 1.0.3 - 2026-03-20
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- Controller handler methods now correctly bind `this` to the controller instance.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
- Config examples use exported `appConfigSchema` instead of inline definitions.
|
|
39
|
+
- Package exports restricted to subpath-only; added `prepack` build enforcement.
|
|
40
|
+
- Added ESLint and Prettier tooling.
|
|
41
|
+
|
|
42
|
+
### CI
|
|
43
|
+
- Publish workflow consolidated into single tag-based `publish.yml` with GitHub Release creation.
|
|
44
|
+
- npm publish via classic `NPM_TOKEN` secret.
|
|
45
|
+
|
|
5
46
|
## 0.1.0 - 2026-02-22
|
|
6
47
|
|
|
7
48
|
- Initial public package setup.
|
package/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# @smounters/imperium
|
|
2
2
|
|
|
3
|
-
`@smounters/imperium` is **inspired by NestJS** and provides a modular DI-first runtime for TypeScript services using Fastify + ConnectRPC.
|
|
3
|
+
`@smounters/imperium` is **inspired by NestJS** and provides a modular DI-first runtime for TypeScript services using Fastify + ConnectRPC + WebSocket.
|
|
4
4
|
|
|
5
5
|
It is designed for teams that want Nest-like architecture but with explicit control over runtime wiring and exported API surface.
|
|
6
6
|
|
|
7
7
|
## Key Features
|
|
8
8
|
|
|
9
9
|
- Nest-like modules and decorators (`@Module`, `@Injectable`, guards/pipes/interceptors/filters).
|
|
10
|
-
- Unified HTTP + RPC server on one Fastify instance.
|
|
10
|
+
- Unified HTTP + RPC + WebSocket server on one Fastify instance.
|
|
11
|
+
- Server streaming RPC via `async function*` (delivered as SSE in browsers).
|
|
12
|
+
- WebSocket gateway with message routing, lifecycle hooks, and guards at connection time.
|
|
11
13
|
- Request-scoped handler execution.
|
|
12
14
|
- Typed runtime config via `zod` + `ConfigService`.
|
|
13
15
|
- Built-in `LoggerService` (tslog-based).
|
|
@@ -25,6 +27,7 @@ Use subpaths only:
|
|
|
25
27
|
- `@smounters/imperium/services`
|
|
26
28
|
- `@smounters/imperium/pipes`
|
|
27
29
|
- `@smounters/imperium/validation`
|
|
30
|
+
- `@smounters/imperium/ws`
|
|
28
31
|
|
|
29
32
|
## Installation
|
|
30
33
|
|
|
@@ -162,9 +165,39 @@ const rules = app.resolveAll<AmlRule>(AML_RULES);
|
|
|
162
165
|
Use decorators from `@smounters/imperium/decorators`:
|
|
163
166
|
|
|
164
167
|
- HTTP: `HttpController`, `Get`, `Post`, `Put`, `Patch`, `Delete`, `Body`, `Query`, `Param`, `Header`, `Req`, `Res`
|
|
165
|
-
- RPC: `RpcService`, `RpcMethod`, `RpcData`, `RpcContext`, `RpcHeaders`, `RpcHeader`
|
|
168
|
+
- RPC: `RpcService`, `RpcMethod`, `RpcData`, `RpcContext`, `RpcHeaders`, `RpcHeader`, `RpcAbortSignal`
|
|
169
|
+
- WebSocket: `WsGateway`, `WsHandler`, `WsConnection`, `WsMessage`, `WsRequest`
|
|
166
170
|
|
|
167
|
-
Imperium auto-detects registered HTTP/RPC handlers and serves
|
|
171
|
+
Imperium auto-detects registered HTTP/RPC/WebSocket handlers and serves all protocols from one server.
|
|
172
|
+
|
|
173
|
+
## WebSocket
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { WsGateway, WsHandler, WsConnection, WsMessage, Module } from "@smounters/imperium/decorators";
|
|
177
|
+
import type { WsGatewayLifecycle } from "@smounters/imperium/ws";
|
|
178
|
+
import type { WebSocket } from "@fastify/websocket";
|
|
179
|
+
|
|
180
|
+
@WsGateway("/ws")
|
|
181
|
+
class ChatGateway implements WsGatewayLifecycle {
|
|
182
|
+
private clients = new Set<WebSocket>();
|
|
183
|
+
|
|
184
|
+
onConnection(socket: WebSocket) { this.clients.add(socket); }
|
|
185
|
+
onDisconnect(socket: WebSocket) { this.clients.delete(socket); }
|
|
186
|
+
|
|
187
|
+
@WsHandler("message")
|
|
188
|
+
onMessage(@WsConnection() ws: WebSocket, @WsMessage() data: { text: string }) {
|
|
189
|
+
const msg = JSON.stringify({ type: "message", data });
|
|
190
|
+
for (const client of this.clients) {
|
|
191
|
+
if (client.readyState === 1) client.send(msg);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@Module({ providers: [ChatGateway] })
|
|
197
|
+
class AppModule {}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Requires optional peer dependency: `pnpm add @fastify/websocket`
|
|
168
201
|
|
|
169
202
|
## Services
|
|
170
203
|
|
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;
|
|
@@ -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);
|
|
@@ -660,6 +675,9 @@ export class AppContainer {
|
|
|
660
675
|
getHttpControllers() {
|
|
661
676
|
return [...this.httpControllers];
|
|
662
677
|
}
|
|
678
|
+
getWsGateways() {
|
|
679
|
+
return [...this.wsGateways];
|
|
680
|
+
}
|
|
663
681
|
setGlobalGuards(guards) {
|
|
664
682
|
this.globalGuards = [...guards];
|
|
665
683
|
}
|
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 {
|
|
@@ -254,6 +256,9 @@ export async function startServer(diOrOptions, options) {
|
|
|
254
256
|
routes: buildConnectRoutes(di),
|
|
255
257
|
});
|
|
256
258
|
}
|
|
259
|
+
if (ws) {
|
|
260
|
+
await registerWsGateways(server, di);
|
|
261
|
+
}
|
|
257
262
|
await server.listen({ port: listenPort, host });
|
|
258
263
|
return server;
|
|
259
264
|
}
|
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 = instance[methodName];
|
|
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);
|
|
@@ -141,7 +149,7 @@ export function createRpcHandler(app, controller, methodName) {
|
|
|
141
149
|
}
|
|
142
150
|
const interceptors = collectInterceptorsForRpc(controller, methodName, app.getGlobalInterceptors()).map((interceptorLike) => resolveEnhancer(scope, interceptorLike));
|
|
143
151
|
const args = buildRpcArgs(payload, context, handler);
|
|
144
|
-
const controllerHandler = instance[methodName];
|
|
152
|
+
const controllerHandler = (instance[methodName].bind(instance));
|
|
145
153
|
let idx = -1;
|
|
146
154
|
const next = async () => {
|
|
147
155
|
idx++;
|
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;
|
|
@@ -69,6 +69,11 @@ export interface RpcArgumentsHost {
|
|
|
69
69
|
getData<T = unknown>(): T | undefined;
|
|
70
70
|
getContext<T = unknown>(): T | undefined;
|
|
71
71
|
}
|
|
72
|
+
export interface WsArgumentsHost {
|
|
73
|
+
getSocket<T = unknown>(): T | undefined;
|
|
74
|
+
getRequest(): FastifyRequest | undefined;
|
|
75
|
+
getMessage<T = unknown>(): T | undefined;
|
|
76
|
+
}
|
|
72
77
|
export interface BaseContext {
|
|
73
78
|
type: ContextType;
|
|
74
79
|
method: string;
|
|
@@ -82,6 +87,11 @@ export interface BaseContext {
|
|
|
82
87
|
data: unknown;
|
|
83
88
|
context: unknown;
|
|
84
89
|
};
|
|
90
|
+
ws?: {
|
|
91
|
+
socket: unknown;
|
|
92
|
+
request: FastifyRequest;
|
|
93
|
+
message?: unknown;
|
|
94
|
+
};
|
|
85
95
|
controller: Constructor;
|
|
86
96
|
handler: Function;
|
|
87
97
|
getType(): ContextType;
|
|
@@ -89,6 +99,7 @@ export interface BaseContext {
|
|
|
89
99
|
getHandler(): Function;
|
|
90
100
|
switchToHttp(): HttpArgumentsHost;
|
|
91
101
|
switchToRpc(): RpcArgumentsHost;
|
|
102
|
+
switchToWs(): WsArgumentsHost;
|
|
92
103
|
}
|
|
93
104
|
export interface Guard {
|
|
94
105
|
canActivate(ctx: BaseContext): Promise<boolean> | boolean;
|
|
@@ -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,167 @@
|
|
|
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
|
+
(async () => {
|
|
108
|
+
try {
|
|
109
|
+
const msg = JSON.parse(raw.toString());
|
|
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
|
+
(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
|
+
app.disposeRequestScope(scope).catch(() => { });
|
|
166
|
+
});
|
|
167
|
+
}
|
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.0
|
|
3
|
+
"version": "1.1.0",
|
|
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": [
|
|
@@ -53,27 +57,48 @@
|
|
|
53
57
|
"engines": {
|
|
54
58
|
"node": ">=20.0.0"
|
|
55
59
|
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "pnpm exec tsc -p tsconfig.json",
|
|
62
|
+
"typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
|
|
63
|
+
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
|
64
|
+
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
|
65
|
+
"format": "prettier . --write",
|
|
66
|
+
"format:check": "prettier . --check",
|
|
67
|
+
"clean": "rm -rf dist",
|
|
68
|
+
"docs:dev": "pnpm exec vitepress dev docs",
|
|
69
|
+
"docs:build": "pnpm exec vitepress build docs",
|
|
70
|
+
"docs:preview": "pnpm exec vitepress preview docs",
|
|
71
|
+
"prepack": "pnpm run clean && pnpm run build",
|
|
72
|
+
"prepublishOnly": "pnpm run clean && pnpm run build"
|
|
73
|
+
},
|
|
56
74
|
"peerDependencies": {
|
|
57
75
|
"@connectrpc/connect": "^2.1.1",
|
|
58
76
|
"@connectrpc/connect-fastify": "^2.1.1",
|
|
59
77
|
"@fastify/cors": "^11.2.0",
|
|
78
|
+
"@fastify/websocket": "^11.0.0",
|
|
60
79
|
"fastify": "^5.7.4",
|
|
61
|
-
"tsyringe": "^4.10.0",
|
|
62
80
|
"reflect-metadata": "^0.2.2",
|
|
63
81
|
"tslog": "^4.10.2",
|
|
82
|
+
"tsyringe": "^4.10.0",
|
|
64
83
|
"typescript": "^5.9.3",
|
|
65
84
|
"zod": "^4.3.6"
|
|
66
85
|
},
|
|
86
|
+
"peerDependenciesMeta": {
|
|
87
|
+
"@fastify/websocket": {
|
|
88
|
+
"optional": true
|
|
89
|
+
}
|
|
90
|
+
},
|
|
67
91
|
"devDependencies": {
|
|
68
|
-
"@types/node": "^24.5.2",
|
|
69
|
-
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
70
|
-
"@typescript-eslint/parser": "^8.56.0",
|
|
71
92
|
"@connectrpc/connect": "^2.1.1",
|
|
72
93
|
"@connectrpc/connect-fastify": "^2.1.1",
|
|
73
94
|
"@fastify/cors": "^11.2.0",
|
|
74
|
-
"
|
|
95
|
+
"@fastify/websocket": "^11.2.0",
|
|
96
|
+
"@types/node": "^25.5.0",
|
|
97
|
+
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
|
98
|
+
"@typescript-eslint/parser": "^8.57.2",
|
|
99
|
+
"eslint": "^10.1.0",
|
|
75
100
|
"eslint-config-prettier": "^10.1.8",
|
|
76
|
-
"fastify": "^5.
|
|
101
|
+
"fastify": "^5.8.4",
|
|
77
102
|
"prettier": "^3.8.1",
|
|
78
103
|
"reflect-metadata": "^0.2.2",
|
|
79
104
|
"tslog": "^4.10.2",
|
|
@@ -81,18 +106,5 @@
|
|
|
81
106
|
"typescript": "^5.9.3",
|
|
82
107
|
"vitepress": "^1.6.4",
|
|
83
108
|
"zod": "^4.3.6"
|
|
84
|
-
},
|
|
85
|
-
"dependencies": {},
|
|
86
|
-
"scripts": {
|
|
87
|
-
"build": "pnpm exec tsc -p tsconfig.json",
|
|
88
|
-
"typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
|
|
89
|
-
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
|
90
|
-
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
|
91
|
-
"format": "prettier . --write",
|
|
92
|
-
"format:check": "prettier . --check",
|
|
93
|
-
"clean": "rm -rf dist",
|
|
94
|
-
"docs:dev": "pnpm exec vitepress dev docs",
|
|
95
|
-
"docs:build": "pnpm exec vitepress build docs",
|
|
96
|
-
"docs:preview": "pnpm exec vitepress preview docs"
|
|
97
109
|
}
|
|
98
|
-
}
|
|
110
|
+
}
|