@smounters/imperium 1.0.3 → 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 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,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 both protocols from one server.
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
 
@@ -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;
@@ -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
  }
@@ -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
- if (!http && !rpc && !healthConfig.enabled) {
155
- throw new Error("No handlers found for HTTP or RPC. Register controllers in @Module().");
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
  }
@@ -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";
@@ -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");
@@ -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].bind(instance));
167
+ const controllerHandler = instance[methodName].bind(instance);
163
168
  let idx = -1;
164
169
  const next = async () => {
165
170
  idx++;
@@ -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);
@@ -1,3 +1,4 @@
1
1
  export * from "./adapter";
2
+ export * from "./streaming-adapter";
2
3
  export * from "./router-builder";
3
4
  export * from "./utils";
package/dist/rpc/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./adapter";
2
+ export * from "./streaming-adapter";
2
3
  export * from "./router-builder";
3
4
  export * from "./utils";
@@ -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
- function assertUnaryRpcMethod(controller, handlerName, method) {
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
- assertUnaryRpcMethod(controller, handlerName, method);
24
- router.rpc(method, createRpcHandler(di, controller, handlerName));
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
+ }
@@ -0,0 +1,3 @@
1
+ export { handleWsConnection } from "./adapter";
2
+ export { registerWsGateways } from "./router-builder";
3
+ export type { WsGatewayLifecycle, WsGatewayMeta, WsHandlerMeta, WsParamMeta, WsParamSource } from "./types";
@@ -0,0 +1,2 @@
1
+ export { handleWsConnection } from "./adapter";
2
+ export { registerWsGateways } from "./router-builder";
@@ -0,0 +1,4 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import "reflect-metadata";
3
+ import type { AppContainer } from "../core/container";
4
+ export declare function registerWsGateways(server: FastifyInstance, app: AppContainer): Promise<void>;
@@ -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
+ }
@@ -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",
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": [
@@ -71,23 +75,30 @@
71
75
  "@connectrpc/connect": "^2.1.1",
72
76
  "@connectrpc/connect-fastify": "^2.1.1",
73
77
  "@fastify/cors": "^11.2.0",
78
+ "@fastify/websocket": "^11.0.0",
74
79
  "fastify": "^5.7.4",
75
- "tsyringe": "^4.10.0",
76
80
  "reflect-metadata": "^0.2.2",
77
81
  "tslog": "^4.10.2",
82
+ "tsyringe": "^4.10.0",
78
83
  "typescript": "^5.9.3",
79
84
  "zod": "^4.3.6"
80
85
  },
86
+ "peerDependenciesMeta": {
87
+ "@fastify/websocket": {
88
+ "optional": true
89
+ }
90
+ },
81
91
  "devDependencies": {
82
- "@types/node": "^24.5.2",
83
- "@typescript-eslint/eslint-plugin": "^8.56.0",
84
- "@typescript-eslint/parser": "^8.56.0",
85
92
  "@connectrpc/connect": "^2.1.1",
86
93
  "@connectrpc/connect-fastify": "^2.1.1",
87
94
  "@fastify/cors": "^11.2.0",
88
- "eslint": "^10.0.0",
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",
89
100
  "eslint-config-prettier": "^10.1.8",
90
- "fastify": "^5.7.4",
101
+ "fastify": "^5.8.4",
91
102
  "prettier": "^3.8.1",
92
103
  "reflect-metadata": "^0.2.2",
93
104
  "tslog": "^4.10.2",
@@ -95,6 +106,5 @@
95
106
  "typescript": "^5.9.3",
96
107
  "vitepress": "^1.6.4",
97
108
  "zod": "^4.3.6"
98
- },
99
- "dependencies": {}
109
+ }
100
110
  }