bazis 0.98.30 → 0.98.32

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.
Files changed (34) hide show
  1. package/core/app/runApp.ts +22 -2
  2. package/core/di/index.ts +1 -0
  3. package/core/di/module/collectModuleControllers.ts +27 -26
  4. package/core/di/module/createContainer.ts +8 -0
  5. package/core/di/module/index.ts +1 -1
  6. package/core/di/module/types/BazisModule.ts +7 -0
  7. package/core/grpc/Grpc.module.ts +1 -2
  8. package/core/grpc/GrpcCall.ts +51 -2
  9. package/core/grpc/GrpcClient.ts +11 -5
  10. package/core/grpc/GrpcClientCall.ts +4 -0
  11. package/core/grpc/GrpcServer.ts +9 -3
  12. package/core/grpc/GrpcTransport.ts +2 -2
  13. package/core/grpc/contracts.ts +11 -4
  14. package/core/grpc/moduleControllers.ts +2 -2
  15. package/core/http/Authorization/bearerToken.ts +2 -2
  16. package/core/http/Authorization/metadata.ts +17 -2
  17. package/core/http/Decorators/controller.ts +1 -1
  18. package/core/http/Errors/upstreamFailure.ts +22 -0
  19. package/core/http/HttpServer.ts +4 -4
  20. package/core/http/Middleware/errorHandler.ts +3 -0
  21. package/core/http/httpModule.ts +5 -8
  22. package/core/http/index.ts +2 -0
  23. package/core/http/options.ts +5 -5
  24. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  25. package/core/scripts/di-generate.ts +2 -2
  26. package/core/websocket/DESIGN.md +13 -14
  27. package/core/websocket/WebSocketService.ts +47 -0
  28. package/core/websocket/adapter/redis-delivery-store.ts +2 -2
  29. package/core/websocket/index.ts +2 -1
  30. package/core/websocket/middleware.ts +20 -9
  31. package/core/websocket/reliable-delivery.ts +2 -1
  32. package/core/websocket/websocketModule.ts +33 -13
  33. package/core/websocket/ws-server.ts +80 -23
  34. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  collectModuleUiProfiles,
3
3
  collectModuleControllers,
4
+ collectModuleGateways,
4
5
  DI,
5
6
  Global,
6
7
  Module,
@@ -12,7 +13,8 @@ import { missingCacheModuleCheck } from "../cache/http/missingCacheModuleCheck";
12
13
  import { httpModule, useModelValidator, type HttpModuleOptions, type ModelValidator } from "../http";
13
14
  import { grpcModule, type GrpcModuleOptions } from "../grpc";
14
15
  import { buildHttpOpenApiDocument } from "../http/OpenApi/openApiDocument";
15
- import { KernelBuilder, Bazis, type KernelOptions, type ValidatableConfig } from "../kernel";
16
+ import { KernelBuilder, Bazis, KernelError, type KernelOptions, type ValidatableConfig } from "../kernel";
17
+ import { websocketModule, type WebSocketOptions } from "../websocket";
16
18
  import {
17
19
  isUiProfileAuthoringClass,
18
20
  type UiProfileV1,
@@ -39,6 +41,12 @@ export interface RunAppOptions {
39
41
  readonly http?: HttpModuleOptions;
40
42
  /** gRPC server; discovers feature-module grpcControllers, independently of HTTP. */
41
43
  readonly grpc?: GrpcModuleOptions;
44
+ /**
45
+ * WebSocket settings (authentication, adapter, limits). Gateways are
46
+ * collected from the `gateways` of the modules and served on the HTTP
47
+ * listener whenever a module declares one; this option only tunes them.
48
+ */
49
+ readonly websocket?: WebSocketOptions;
42
50
  /** Kernel options (environment, shutdown timeout, signals, ...). */
43
51
  readonly kernel?: KernelOptions;
44
52
  /**
@@ -88,6 +96,17 @@ export interface RunAppOptions {
88
96
  readonly ui?: RunAppUiOptions;
89
97
  }
90
98
 
99
+ /** Adds the WebSocket module when a module declares gateways; settings alone without gateways are a mistake. */
100
+ function withWebSocket(root: BazisModuleRef, options: WebSocketOptions | undefined): BazisModuleRef {
101
+ if (collectModuleGateways([root]).length === 0) {
102
+ if (options !== undefined) {
103
+ throw new KernelError("runApp: websocket options are set, but no module declares gateways. Add gateways: [ChatGateway] to the module that owns the gateway.");
104
+ }
105
+ return root;
106
+ }
107
+ return { imports: [root, websocketModule({ ...options, imports: [root] })] };
108
+ }
109
+
91
110
  function withUiRuntime(
92
111
  root: BazisModuleRef,
93
112
  option: RunAppOptions["ui"],
@@ -279,8 +298,9 @@ export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): B
279
298
  // behavior after startup.
280
299
  useModelValidator(validator);
281
300
 
301
+ const gatewayRoot = withWebSocket(appRoot, options.websocket);
282
302
  const httpAppRoot = withUiRuntime(
283
- { imports: [appRoot, ...(options.http.imports ?? [])] },
303
+ { imports: [gatewayRoot, ...(options.http.imports ?? [])] },
284
304
  options.ui,
285
305
  options.http,
286
306
  );
package/core/di/index.ts CHANGED
@@ -24,6 +24,7 @@ export {
24
24
  createContainer,
25
25
  type CreateContainerOptions,
26
26
  collectModuleControllers,
27
+ collectModuleGateways,
27
28
  collectModuleConfigs,
28
29
  collectModuleUiProfiles,
29
30
  Global,
@@ -1,16 +1,26 @@
1
1
  import type { Class } from "../token";
2
2
  import type { BazisModuleRef } from "./types";
3
3
 
4
+ type ClassListKey = "controllers" | "gateways";
5
+
4
6
  /**
5
- * Collects controller classes from the module tree (the module itself plus `imports`,
6
- * recursively). Duplicates by class reference are dropped.
7
+ * Collects the classes of one declarative list (`controllers`, `gateways`) from
8
+ * the module tree (the module itself plus `imports`, recursively). Duplicates by
9
+ * class reference are dropped.
7
10
  */
8
- export function collectModuleControllers(
9
- roots: readonly BazisModuleRef[],
10
- extraControllers?: readonly Class<object>[],
11
- ): Class<object>[] {
11
+ function collectModuleClasses(roots: readonly BazisModuleRef[], key: ClassListKey): Class<object>[] {
12
12
  const seen = new Set<Class<object>>();
13
13
  const result: Class<object>[] = [];
14
+ const add = (list: readonly Class<object>[] | undefined): void => {
15
+ if (!list) return;
16
+ for (let index = 0; index < list.length; index += 1) {
17
+ const entry = list[index] as Class<object>;
18
+ if (!seen.has(entry)) {
19
+ seen.add(entry);
20
+ result.push(entry);
21
+ }
22
+ }
23
+ };
14
24
 
15
25
  const visit = (moduleRef: BazisModuleRef, loaded: Set<BazisModuleRef>): void => {
16
26
  if (loaded.has(moduleRef)) {
@@ -24,30 +34,21 @@ export function collectModuleControllers(
24
34
  visit(imports[index] as BazisModuleRef, loaded);
25
35
  }
26
36
  }
27
-
28
- const controllers = moduleRef.controllers;
29
- if (controllers) {
30
- for (let index = 0; index < controllers.length; index += 1) {
31
- const controller = controllers[index] as Class<object>;
32
- if (!seen.has(controller)) {
33
- seen.add(controller);
34
- result.push(controller);
35
- }
36
- }
37
- }
37
+ add(moduleRef[key]);
38
38
  };
39
39
 
40
40
  for (let index = 0; index < roots.length; index += 1) {
41
41
  visit(roots[index] as BazisModuleRef, new Set());
42
42
  }
43
- if (extraControllers) {
44
- for (let index = 0; index < extraControllers.length; index += 1) {
45
- const controller = extraControllers[index] as Class<object>;
46
- if (!seen.has(controller)) {
47
- seen.add(controller);
48
- result.push(controller);
49
- }
50
- }
51
- }
52
43
  return result;
53
44
  }
45
+
46
+ /** Controller classes (`controllers` of `@Module`) of the module tree. */
47
+ export function collectModuleControllers(roots: readonly BazisModuleRef[]): Class<object>[] {
48
+ return collectModuleClasses(roots, "controllers");
49
+ }
50
+
51
+ /** WebSocket gateway classes (`gateways` of `@Module`) of the module tree. */
52
+ export function collectModuleGateways(roots: readonly BazisModuleRef[]): Class<object>[] {
53
+ return collectModuleClasses(roots, "gateways");
54
+ }
@@ -144,6 +144,14 @@ export function createContainer(rootModule: BazisModuleRef, options?: CreateCont
144
144
  }
145
145
  }
146
146
 
147
+ const gateways = moduleRef.gateways;
148
+ if (gateways) {
149
+ for (let index = 0; index < gateways.length; index += 1) {
150
+ // One instance per gateway: it keeps connection state across requests.
151
+ collection.add(singleton(gateways[index] as Class<object>));
152
+ }
153
+ }
154
+
147
155
  const background = moduleRef.background;
148
156
  if (background) {
149
157
  for (let index = 0; index < background.length; index += 1) {
@@ -26,7 +26,7 @@ export {
26
26
  type ModuleOwnedProviderContributor,
27
27
  } from "./moduleOwnedProviderContributors";
28
28
  export { createContainer, type CreateContainerOptions } from "./createContainer";
29
- export { collectModuleControllers } from "./collectModuleControllers";
29
+ export { collectModuleControllers, collectModuleGateways } from "./collectModuleControllers";
30
30
  export { collectModuleConfigs } from "./collectModuleConfigs";
31
31
  export { collectModuleUiProfiles } from "./collectModuleUiProfiles";
32
32
  export {
@@ -29,6 +29,13 @@ export interface BazisModuleMetadata {
29
29
  * backward compatibility.
30
30
  */
31
31
  readonly controllers?: readonly Class<object>[];
32
+ /**
33
+ * WebSocket gateways of the module (classes with `@WebSocketGateway`).
34
+ * Registered as singletons in this module, so a gateway sees the module's
35
+ * providers like a controller does; `websocketModule` collects them from
36
+ * `imports` and serves them on the HTTP listener.
37
+ */
38
+ readonly gateways?: readonly Class<object>[];
32
39
  /**
33
40
  * `@UiProfile()` declarations owned by this module. The DI layer keeps them
34
41
  * opaque; `runApp` resolves their controller/request/response references and
@@ -5,10 +5,9 @@ import { collectGrpcControllers, GRPC_CONTROLLERS } from "./moduleControllers";
5
5
 
6
6
  /** gRPC counterpart of httpModule: controllers belong to their feature modules. */
7
7
  export function grpcModule(options: GrpcModuleOptions = {}): BazisModule {
8
- const selected = collectGrpcControllers(options.imports ?? [], options.controllers);
8
+ const selected = collectGrpcControllers(options.imports ?? []);
9
9
  @Module({
10
10
  imports: options.imports,
11
- grpcControllers: options.controllers,
12
11
  providers: [DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => {
13
12
  const container = resolver.resolve(SERVICE_PROVIDER);
14
13
  if (!(container instanceof DiContainer)) throw new TypeError("gRPC requires a module DI container.");
@@ -1,5 +1,11 @@
1
1
  import type { ModuleOwnedProviderActivation, ServiceProvider } from "../di";
2
2
  import type { GrpcContext } from "./contracts";
3
+ import { GrpcError } from "./GrpcError";
4
+ import type { AuthorizeCheck } from "../http/Authorization/metadata";
5
+ import { ForbiddenError, UnauthorizedError } from "../http/Errors/HttpError";
6
+ import { JwtError } from "../../library/jwt/errors";
7
+ import { redactSensitive } from "../../library/redaction";
8
+ import type { Logger } from "../kernel/logging/Logger";
3
9
  import type { MethodDefinition } from "./serviceDefinition";
4
10
  import { GrpcTransport } from "./GrpcTransport";
5
11
  import { GrpcStatus } from "./GrpcStatus";
@@ -14,13 +20,25 @@ export class GrpcCall {
14
20
  private readonly activation: ModuleOwnedProviderActivation<object>,
15
21
  private readonly provider: ServiceProvider,
16
22
  private readonly binding?: GrpcRequestBinding,
23
+ /** `@Authorize` checks of the controller and the method, in order. */
24
+ private readonly checks: readonly AuthorizeCheck[] = [],
25
+ private readonly logger?: Logger,
17
26
  ) {}
18
27
 
19
28
  cancel(): void { this.transport.cancel(GrpcStatus.UNAVAILABLE, "Server stopped."); }
20
29
 
21
30
  async run(): Promise<void> {
22
31
  const scope = this.provider.createScope();
23
- const context = this.transport.context;
32
+ const info = this.transport.context;
33
+ const context: GrpcContext = Object.freeze({
34
+ ...info,
35
+ services: scope,
36
+ state: new Map<string, unknown>(),
37
+ header: (name: string) => {
38
+ const value = info.metadata.get(name)[0];
39
+ return typeof value === "string" ? value : undefined;
40
+ },
41
+ });
24
42
  let result: unknown;
25
43
  let failure: unknown;
26
44
  let failed = false;
@@ -35,6 +53,9 @@ export class GrpcCall {
35
53
  for await (const value of requests) yield bind(value);
36
54
  }
37
55
  try {
56
+ context.signal.throwIfAborted();
57
+ // Like HTTP: access is decided before the request is read.
58
+ await authorize(this.checks, context);
38
59
  context.signal.throwIfAborted();
39
60
  const input = this.definition.requestStream
40
61
  ? (this.binding ? validatedRequests() : requests)
@@ -62,7 +83,14 @@ export class GrpcCall {
62
83
  // Do not dispose dependencies while non-cooperative handler code is running.
63
84
  try { await scope.dispose(); } catch (error) { failure = error; failed = true; }
64
85
  }
65
- if (failed) this.transport.fail(failure);
86
+ if (failed) {
87
+ // An intentional GrpcError and a cancelled call are not server failures;
88
+ // anything else is a bug and belongs in the log, like an HTTP 500.
89
+ if (!(failure instanceof GrpcError) && !context.signal.aborted) {
90
+ this.logger?.error("gRPC handler failed", { path: this.definition.path, error: redactSensitive(failure) });
91
+ }
92
+ this.transport.fail(failure);
93
+ }
66
94
  else {
67
95
  try {
68
96
  if (!this.definition.responseStream) await this.transport.write(result);
@@ -71,3 +99,24 @@ export class GrpcCall {
71
99
  }
72
100
  }
73
101
  }
102
+
103
+ /**
104
+ * Runs the `@Authorize` checks: `false` and `ForbiddenError` are
105
+ * PERMISSION_DENIED, `UnauthorizedError` and `JwtError` (missing, malformed,
106
+ * expired token) are UNAUTHENTICATED. Any other error propagates as a bug.
107
+ */
108
+ async function authorize(checks: readonly AuthorizeCheck[], context: GrpcContext): Promise<void> {
109
+ for (const check of checks) {
110
+ let allowed: boolean;
111
+ try {
112
+ allowed = await check(context);
113
+ } catch (error) {
114
+ if (error instanceof UnauthorizedError || error instanceof JwtError) {
115
+ throw new GrpcError(GrpcStatus.UNAUTHENTICATED, "Unauthenticated.");
116
+ }
117
+ if (error instanceof ForbiddenError) throw new GrpcError(GrpcStatus.PERMISSION_DENIED, "Permission denied.");
118
+ throw error;
119
+ }
120
+ if (!allowed) throw new GrpcError(GrpcStatus.PERMISSION_DENIED, "Permission denied.");
121
+ }
122
+ }
@@ -2,6 +2,7 @@ import { connect, type ClientHttp2Session, type SecureClientSessionOptions } fro
2
2
  import type { GrpcCallOptions, GrpcClientOptions, GrpcResponse, GrpcResponseStream } from "./clientContracts";
3
3
  import { GrpcClientCall } from "./GrpcClientCall";
4
4
  import { GrpcError } from "./GrpcError";
5
+ import { markUpstreamFailure } from "../http/Errors/upstreamFailure";
5
6
  import { GrpcStatus } from "./GrpcStatus";
6
7
  import { Metadata } from "./Metadata";
7
8
  import type { MethodDefinition, ServiceDefinition } from "./serviceDefinition";
@@ -114,10 +115,10 @@ export class GrpcClient {
114
115
  deadline = Math.min(deadline, explicit);
115
116
  }
116
117
  if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError("Invalid gRPC signal.");
117
- if (options.signal?.aborted) throw new GrpcError(GrpcStatus.CANCELLED, "RPC cancelled.");
118
- if (deadline <= Date.now()) throw new GrpcError(GrpcStatus.DEADLINE_EXCEEDED, "Deadline exceeded.");
119
- if (this.closed) throw new GrpcError(GrpcStatus.CANCELLED, "gRPC client is closed.");
120
- if (this.calls.size >= this.callLimit) throw new GrpcError(GrpcStatus.RESOURCE_EXHAUSTED, "Too many concurrent gRPC calls.");
118
+ if (options.signal?.aborted) throw upstream(new GrpcError(GrpcStatus.CANCELLED, "RPC cancelled."));
119
+ if (deadline <= Date.now()) throw upstream(new GrpcError(GrpcStatus.DEADLINE_EXCEEDED, "Deadline exceeded."));
120
+ if (this.closed) throw upstream(new GrpcError(GrpcStatus.CANCELLED, "gRPC client is closed."));
121
+ if (this.calls.size >= this.callLimit) throw upstream(new GrpcError(GrpcStatus.RESOURCE_EXHAUSTED, "Too many concurrent gRPC calls."));
121
122
  let session: ClientHttp2Session, stream: ReturnType<ClientHttp2Session["request"]>;
122
123
  try {
123
124
  session = this.session();
@@ -125,7 +126,7 @@ export class GrpcClient {
125
126
  const wireTimeout = remaining <= 99_999_999 ? `${remaining}m` : `${Math.ceil(remaining / 1000)}S`;
126
127
  stream = session.request({ ...metadata.toHttp2Headers(), ":method": "POST", ":path": method.path,
127
128
  "content-type": "application/grpc+proto", te: "trailers", "grpc-accept-encoding": "identity", "grpc-timeout": wireTimeout }, { endStream: false });
128
- } catch { throw new GrpcError(GrpcStatus.UNAVAILABLE, "Unable to open gRPC connection."); }
129
+ } catch { throw upstream(new GrpcError(GrpcStatus.UNAVAILABLE, "Unable to open gRPC connection.")); }
129
130
  const call = new GrpcClientCall<T>(stream, method, { deadline, signal: options.signal, sendLimit: this.sendLimit, receiveLimit: this.receiveLimit }, () => {
130
131
  this.calls.delete(call);
131
132
  this.sessions.get(session)?.delete(call);
@@ -170,3 +171,8 @@ function metadataCopy(value: Metadata | undefined): Metadata {
170
171
  if (!(value instanceof Metadata)) throw new TypeError("Expected Bazis Metadata.");
171
172
  return value.clone();
172
173
  }
174
+
175
+ /** A failed call of this client is a failure of another service for an HTTP request: 504 or 502. */
176
+ function upstream(error: GrpcError): GrpcError {
177
+ return markUpstreamFailure(error, error.code === GrpcStatus.DEADLINE_EXCEEDED ? "timeout" : "failure");
178
+ }
@@ -1,4 +1,5 @@
1
1
  import { constants, type Http2Session, type ClientHttp2Stream, type IncomingHttpHeaders } from "node:http2";
2
+ import { markUpstreamFailure } from "../http/Errors/upstreamFailure";
2
3
  import type { GrpcResponseStream } from "./clientContracts";
3
4
  import { GrpcError } from "./GrpcError";
4
5
  import { GrpcStatus } from "./GrpcStatus";
@@ -83,6 +84,9 @@ export class GrpcClientCall<T> implements GrpcResponseStream<T> {
83
84
  if (!this.terminalHeaders) this.fail(new GrpcError(GrpcStatus.UNAVAILABLE, "gRPC connection closed before completion."));
84
85
  }
85
86
  fail(error: GrpcError): void {
87
+ // In an HTTP request this is a failure of another service: 504 or 502.
88
+ // Marked even after finish: the same error is rethrown to the caller.
89
+ markUpstreamFailure(error, error.code === GrpcStatus.DEADLINE_EXCEEDED ? "timeout" : "failure");
86
90
  if (this.finished) return;
87
91
  this.failure = error;
88
92
  this.abort.abort(error);
@@ -11,6 +11,8 @@ import type { MethodDefinition } from "./serviceDefinition";
11
11
  import { modelValidatorAdapter } from "../../library/validation";
12
12
  import { getGrpcRequestBinding } from "./GrpcBinding.contract";
13
13
  import { GrpcRequestBinding } from "./GrpcRequest.binding";
14
+ import { resolveAuthorizeMeta, type AuthorizeCheck } from "../http/Authorization/metadata";
15
+ import { LOGGER, type Logger } from "../kernel/logging/Logger";
14
16
 
15
17
  type Contribution = ModuleOwnedProviderContribution<GrpcControllerDefinition, object>;
16
18
 
@@ -24,7 +26,8 @@ export class GrpcServer implements HostedService {
24
26
  private readonly sendLimit: number;
25
27
  private readonly maxConcurrentCalls: number;
26
28
  private readonly shutdownTimeoutMs: number;
27
- private readonly routes = new Map<string, { entry: Contribution; method: MethodDefinition; name: string | symbol; binding?: GrpcRequestBinding }>();
29
+ private readonly routes = new Map<string, { entry: Contribution; method: MethodDefinition; name: string | symbol; binding?: GrpcRequestBinding; checks: readonly AuthorizeCheck[] }>();
30
+ private readonly logger?: Logger;
28
31
  private readonly sessions = new Set<ServerHttp2Session>();
29
32
  private readonly active = new Map<GrpcCall, Promise<void>>();
30
33
  private state: "idle" | "starting" | "running" | "stopping" | "stopped" = "idle";
@@ -34,6 +37,7 @@ export class GrpcServer implements HostedService {
34
37
  private boundPort?: number;
35
38
 
36
39
  constructor(options: GrpcModuleOptions, private readonly provider: ServiceProvider, controllers: readonly Contribution[]) {
40
+ this.logger = provider.tryResolve(LOGGER);
37
41
  const address = options.address ?? "127.0.0.1:50051";
38
42
  const match = typeof address === "string" && /^(?:\[([^\]]+)\]|([^:[\]\s]+)):(\d{1,5})$/.exec(address);
39
43
  if (!match || Number(match[3]) > 65535 || options.address === null) throw new TypeError("gRPC address must be host:port or [IPv6]:port.");
@@ -68,7 +72,9 @@ export class GrpcServer implements HostedService {
68
72
  throw new TypeError(`gRPC request stream/DTO mismatch: ${method.path}.`);
69
73
  }
70
74
  const model = explicit ?? generated?.model;
71
- this.routes.set(method.path, { entry, method, name, binding: model ? new GrpcRequestBinding(model, validator) : undefined });
75
+ const access = resolveAuthorizeMeta(entry.payload.controller, name);
76
+ const checks = access.allowAnonymous ? [] : access.authorize?.checks ?? [];
77
+ this.routes.set(method.path, { entry, method, name, binding: model ? new GrpcRequestBinding(model, validator) : undefined, checks });
72
78
  }
73
79
  }
74
80
  const tls = credentials.http2Options();
@@ -113,7 +119,7 @@ export class GrpcServer implements HostedService {
113
119
  reject(error instanceof GrpcError ? error.code : GrpcStatus.INVALID_ARGUMENT, error instanceof GrpcError ? error.message : "Invalid gRPC headers.");
114
120
  return;
115
121
  }
116
- const execution = new GrpcCall(transport, route.method, route.name, route.entry.activation, this.provider, route.binding);
122
+ const execution = new GrpcCall(transport, route.method, route.name, route.entry.activation, this.provider, route.binding, route.checks, this.logger);
117
123
  const pending = execution.run().catch((error: unknown) => transport.fail(error))
118
124
  .finally(() => this.active.delete(execution));
119
125
  this.active.set(execution, pending);
@@ -1,5 +1,5 @@
1
1
  import type { IncomingHttpHeaders, ServerHttp2Stream } from "node:http2";
2
- import type { GrpcContext } from "./contracts";
2
+ import type { GrpcCallInfo } from "./contracts";
3
3
  import { Metadata } from "./Metadata";
4
4
  import { GrpcError } from "./GrpcError";
5
5
  import { GrpcStatus } from "./GrpcStatus";
@@ -7,7 +7,7 @@ import type { MethodDefinition } from "./serviceDefinition";
7
7
 
8
8
  /** Owns the standard five-byte gRPC envelope and one built-in HTTP/2 stream. */
9
9
  export class GrpcTransport {
10
- readonly context: GrpcContext;
10
+ readonly context: GrpcCallInfo;
11
11
  private readonly abort = new AbortController();
12
12
  private sentHeaders = false;
13
13
  private ended = false;
@@ -4,9 +4,10 @@ import type { ServerOptions, ServiceDefinition } from "./serviceDefinition";
4
4
  import type { Class, BazisModuleRef } from "../di";
5
5
  import type { ModelValidator } from "../http/Binding/modelValidator";
6
6
  import type { RequestModelClass } from "../http/Binding/requestModelRegistry";
7
+ import type { AuthorizeContext } from "../http/Authorization/metadata";
7
8
 
8
- /** One instance per RPC, including the complete lifetime of a streaming RPC. */
9
- export interface GrpcContext {
9
+ /** Transport facts of one RPC, before its DI scope exists. */
10
+ export interface GrpcCallInfo {
10
11
  readonly metadata: Metadata;
11
12
  readonly signal: AbortSignal;
12
13
  readonly deadline: Date | number;
@@ -15,10 +16,16 @@ export interface GrpcContext {
15
16
  sendMetadata(metadata: Metadata): void;
16
17
  }
17
18
 
19
+ /**
20
+ * One instance per RPC, including the complete lifetime of a streaming RPC.
21
+ * It is also what `@Authorize` checks see: `header(name)` reads text metadata,
22
+ * `services` is the RPC scope, `state` is shared with the handler.
23
+ */
24
+ export interface GrpcContext extends GrpcCallInfo, AuthorizeContext {}
25
+
18
26
  export interface GrpcModuleOptions {
27
+ /** Modules whose `grpcControllers` are served (the whole tree). */
19
28
  readonly imports?: readonly BazisModuleRef[];
20
- /** Normally contributed by feature-module grpcControllers. */
21
- readonly controllers?: readonly Class<object>[];
22
29
  /** DTO validation, captured per server. Default: Bazis's modelValidatorAdapter. */
23
30
  readonly validator?: ModelValidator;
24
31
  /** host:port or [IPv6]:port, default 127.0.0.1:50051. Port 0 selects a free port. */
@@ -24,8 +24,8 @@ registerModuleOwnedProviderContributor((metadata, context) => {
24
24
  });
25
25
 
26
26
  /** @internal Discover only the feature tree published by this transport. */
27
- export function collectGrpcControllers(roots: readonly BazisModuleRef[], extra: readonly Class<object>[] = []): Set<Class<object>> {
28
- const result = new Set(extra);
27
+ export function collectGrpcControllers(roots: readonly BazisModuleRef[]): Set<Class<object>> {
28
+ const result = new Set<Class<object>>();
29
29
  const visited = new Set<BazisModuleRef>();
30
30
  const visit = (module: BazisModuleRef): void => {
31
31
  if (visited.has(module)) return;
@@ -1,11 +1,11 @@
1
- import type { HttpContext } from "../HttpContext/HttpContext";
1
+ import type { AuthorizeContext } from "./metadata";
2
2
 
3
3
  /**
4
4
  * The token of an `Authorization: Bearer <token>` header, or `undefined`
5
5
  * without the header, with another scheme or with an empty token. The scheme
6
6
  * is case-insensitive (RFC 7235); surrounding spaces are trimmed.
7
7
  */
8
- export function bearerToken(ctx: Pick<HttpContext, "header">): string | undefined {
8
+ export function bearerToken(ctx: Pick<AuthorizeContext, "header">): string | undefined {
9
9
  const match = /^\s*bearer\s+(\S+)\s*$/i.exec(ctx.header("authorization") ?? "");
10
10
  return match?.[1];
11
11
  }
@@ -1,4 +1,4 @@
1
- import type { HttpContext } from "../HttpContext/HttpContext";
1
+ import type { ServiceScope } from "../../di";
2
2
 
3
3
  // Same one-line polyfill as the rest of the HTTP module: Bun executes TC39
4
4
  // decorators natively, but Symbol.metadata may be missing in the runtime.
@@ -6,6 +6,19 @@ import type { HttpContext } from "../HttpContext/HttpContext";
6
6
 
7
7
  const AUTHORIZE_META = Symbol.for("bazis:http:authorize");
8
8
 
9
+ /**
10
+ * What an access check sees. An HTTP request (`HttpContext`) and a gRPC call
11
+ * (`GrpcContext`) both provide it, so one check protects both transports.
12
+ */
13
+ export interface AuthorizeContext {
14
+ /** A request header (HTTP) or a text metadata value (gRPC), by case-insensitive name. */
15
+ header(name: string): string | undefined;
16
+ /** The DI scope of this request or call. */
17
+ readonly services: ServiceScope;
18
+ /** Values shared by the checks and the handler of this request or call. */
19
+ readonly state: Map<string, unknown>;
20
+ }
21
+
9
22
  /**
10
23
  * Access check for `@Authorize`. Receives the request context and decides
11
24
  * whether to let it through. The HTTP kernel does not know *what* is checked
@@ -18,8 +31,10 @@ const AUTHORIZE_META = Symbol.for("bazis:http:authorize");
18
31
  * (for example `UnauthorizedError` → `401` when there are no credentials at all).
19
32
  *
20
33
  * Dependencies (services, DB) come from `ctx.services` (the request scope).
34
+ * On a gRPC call `false`/`ForbiddenError` is PERMISSION_DENIED and
35
+ * `UnauthorizedError`/`JwtError` is UNAUTHENTICATED.
21
36
  */
22
- export type AuthorizeCheck = (ctx: HttpContext) => boolean | Promise<boolean>;
37
+ export type AuthorizeCheck = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
23
38
 
24
39
  /** Authorization requirement: one or more checks (all must pass). */
25
40
  export interface AuthorizeOptions {
@@ -12,7 +12,7 @@ import { ownMeta } from "./metadata";
12
12
  * ```
13
13
  *
14
14
  * The controller is a regular DI service: register it in a module
15
- * (`controllers: [...]` on `BazisModule`, or `httpModule({ controllers: [...] })`)
15
+ * (`controllers: [...]` on `BazisModule`)
16
16
  * with scoped lifetime and declare constructor dependencies as usual — auto deps work.
17
17
  */
18
18
  export function Controller(prefix = "") {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Marks an error of a call to another service. The HTTP error boundary answers
3
+ * `504 Gateway Timeout` for a timeout and `502 Bad Gateway` otherwise, like it
4
+ * does for `HttpClientError`. Clients of other protocols (gRPC) set the mark,
5
+ * so the HTTP layer does not depend on them.
6
+ */
7
+ export type UpstreamFailure = "timeout" | "failure";
8
+
9
+ const UPSTREAM_FAILURE = Symbol.for("bazis.http.upstreamFailure");
10
+
11
+ /** Sets the mark on `error` (non-enumerable) and returns it. */
12
+ export function markUpstreamFailure<E extends object>(error: E, kind: UpstreamFailure): E {
13
+ Object.defineProperty(error, UPSTREAM_FAILURE, { value: kind, configurable: true });
14
+ return error;
15
+ }
16
+
17
+ /** The mark of an upstream failure, if any. */
18
+ export function upstreamFailure(error: unknown): UpstreamFailure | undefined {
19
+ if (error === null || typeof error !== "object") return undefined;
20
+ const kind = (error as { [UPSTREAM_FAILURE]?: unknown })[UPSTREAM_FAILURE];
21
+ return kind === "timeout" || kind === "failure" ? kind : undefined;
22
+ }
@@ -19,7 +19,7 @@ import type { Router } from "./Routing/Router";
19
19
  import { parseRequestPath } from "./Routing/template";
20
20
  import { assertVersioningOptions, createVersionReader, type VersionReader } from "./Versioning/VersionReader";
21
21
  import { HTTP_ERROR_HOOK, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOrder } from "./middlewareTokens";
22
- import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions } from "./options";
22
+ import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions, HttpServerOptions } from "./options";
23
23
  import { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
24
24
  import { HttpSetupError } from "./Errors/HttpError";
25
25
  import { getModelValidator, type ModelValidator } from "./Binding/modelValidator";
@@ -96,7 +96,7 @@ export class HttpServer implements HostedService {
96
96
  private docs?: PreparedOpenApiDocs;
97
97
 
98
98
  constructor(
99
- private readonly options: HttpModuleOptions,
99
+ private readonly options: HttpServerOptions,
100
100
  /** Root container: the per-request scope factory. */
101
101
  private readonly provider: ServiceProvider,
102
102
  /** Root resolver for optional kernel services (Environment). */
@@ -181,11 +181,11 @@ export class HttpServer implements HostedService {
181
181
  this.options.versioning,
182
182
  this.collectRouteComposer(),
183
183
  );
184
- this.router = builder.build(this.options.controllers as readonly Class<object>[]);
184
+ this.router = builder.build(this.options.controllers);
185
185
  this.readVersion = createVersionReader(this.options.versioning);
186
186
  this.docs = prepareOpenApiDocs({
187
187
  option: this.options.docs,
188
- controllers: this.options.controllers as readonly Class<object>[],
188
+ controllers: this.options.controllers,
189
189
  globalPrefix: this.options.prefix,
190
190
  versioning: this.options.versioning,
191
191
  environment: this.resolver.tryResolve(Environment),
@@ -3,6 +3,7 @@ import { redactSensitive, redactSensitiveText } from "../../../library/redaction
3
3
  import { HttpClientError, HttpErrorCode } from "../../../library/http-client/errors";
4
4
  import type { HttpContext } from "../HttpContext/HttpContext";
5
5
  import { HttpError } from "../Errors/HttpError";
6
+ import { upstreamFailure } from "../Errors/upstreamFailure";
6
7
  import type { HttpMiddleware } from "./types";
7
8
 
8
9
  export interface ErrorHandlerOptions {
@@ -162,6 +163,8 @@ function unexpectedStatus(error: unknown, mapper?: (error: unknown) => number |
162
163
  if (error instanceof HttpClientError && error.code !== undefined) {
163
164
  return error.code === HttpErrorCode.Timeout ? [504, "Gateway Timeout"] : [502, "Bad Gateway"];
164
165
  }
166
+ const upstream = upstreamFailure(error);
167
+ if (upstream !== undefined) return upstream === "timeout" ? [504, "Gateway Timeout"] : [502, "Bad Gateway"];
165
168
  let mapped: number | undefined;
166
169
  try { mapped = mapper?.(error); } catch { mapped = undefined; }
167
170
  if (mapped !== undefined && Number.isInteger(mapped) && mapped >= 400 && mapped <= 599) return [mapped, REASONS[mapped] ?? (mapped >= 500 ? "Server Error" : "Client Error")];
@@ -1,10 +1,10 @@
1
1
  import { DI, HOSTED_SERVICE, Module, SERVICE_PROVIDER, collectModuleControllers, type BazisModule, type ProviderDefinition } from "../di";
2
2
  import { HttpServer } from "./HttpServer";
3
- import type { HttpModuleOptions } from "./options";
3
+ import type { HttpModuleOptions, HttpServerOptions } from "./options";
4
4
 
5
5
  /**
6
- * Builds the HTTP module: discovers controllers from `imports` (and optional
7
- * `controllers` on feature modules) and registers the HttpServer hosted service.
6
+ * Builds the HTTP module: discovers the `controllers` of the modules in
7
+ * `imports` and registers the HttpServer hosted service.
8
8
  *
9
9
  * ```ts
10
10
  * @Module({
@@ -22,11 +22,9 @@ import type { HttpModuleOptions } from "./options";
22
22
  * ```
23
23
  */
24
24
  export function httpModule(options: HttpModuleOptions): BazisModule {
25
- const allControllers = collectModuleControllers(options.imports ?? [], options.controllers);
26
-
27
- const serverOptions: HttpModuleOptions = {
25
+ const serverOptions: HttpServerOptions = {
28
26
  ...options,
29
- controllers: allControllers,
27
+ controllers: collectModuleControllers(options.imports ?? []),
30
28
  };
31
29
 
32
30
  const providers: ProviderDefinition[] = [
@@ -40,7 +38,6 @@ export function httpModule(options: HttpModuleOptions): BazisModule {
40
38
  @Module({
41
39
  imports: options.imports,
42
40
  providers,
43
- controllers: options.controllers,
44
41
  })
45
42
  class HttpModule {}
46
43
 
@@ -39,6 +39,7 @@ export { anyOf } from "./Authorization/anyOf";
39
39
  export {
40
40
  resolveAuthorizeMeta,
41
41
  type AuthorizeCheck,
42
+ type AuthorizeContext,
42
43
  type AuthorizeOptions,
43
44
  type ResolvedAuthorizeMeta,
44
45
  } from "./Authorization/metadata";
@@ -140,3 +141,4 @@ export {
140
141
  UnauthorizedError,
141
142
  UnsupportedMediaTypeError,
142
143
  } from "./Errors/HttpError";
144
+ export { markUpstreamFailure, upstreamFailure, type UpstreamFailure } from "./Errors/upstreamFailure";
@@ -56,11 +56,6 @@ export interface HttpModuleOptions {
56
56
  * Authorized/CORS-allowed clients can then inspect each redirect Location.
57
57
  */
58
58
  readonly inspectableRedirects?: boolean;
59
- /**
60
- * Controller classes (decorated with @Controller). Optional when controllers
61
- * are declared on imported modules via `controllers` on `BazisModule`.
62
- */
63
- readonly controllers?: readonly Class<object>[];
64
59
  /** Request-model validator captured by this HTTP server instance. */
65
60
  readonly validator?: ModelValidator;
66
61
  /**
@@ -136,3 +131,8 @@ export interface HttpModuleOptions {
136
131
  /** Hosted-service startup phase (default 10 — after infrastructure). */
137
132
  readonly phase?: number;
138
133
  }
134
+
135
+ /** @internal What `httpModule` hands to the server: the options plus the controllers it collected. */
136
+ export interface HttpServerOptions extends HttpModuleOptions {
137
+ readonly controllers: readonly Class<object>[];
138
+ }
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.98.30" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.32" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -212,7 +212,7 @@ let explicitRequestModelRoots = new Set<ts.ClassDeclaration>();
212
212
  let agentCodegenSources: AgentCodegenSource[] = [];
213
213
 
214
214
  const ROUTE_DECORATORS = new Set(["Get", "Post", "Put", "Patch", "Delete", "Options", "Head", "All"]);
215
- const CODEGEN_CHANNEL_DECORATORS = new Set(["Controller", "GrpcController", "Agent", "Tool", "Prompt", "RequestModel", "UiProfile"]);
215
+ const CODEGEN_CHANNEL_DECORATORS = new Set(["Controller", "GrpcController", "WebSocketGateway", "Agent", "Tool", "Prompt", "RequestModel", "UiProfile"]);
216
216
  const PRIMITIVE_QUERY_TYPES: Record<string, HttpBindingSpec["type"]> = {
217
217
  string: "string",
218
218
  number: "number",
@@ -867,7 +867,7 @@ function collectDiClassRegistrations(source: ts.SourceFile): void {
867
867
  if (!object || visitedModules.has(object)) return;
868
868
  visitedModules.add(object);
869
869
  const properties = propertiesOf(object);
870
- for (const name of ["controllers", "grpcControllers", "background", "tools"]) {
870
+ for (const name of ["controllers", "grpcControllers", "gateways", "background", "tools"]) {
871
871
  const classes = properties.get(name);
872
872
  if (classes && ts.isArrayLiteralExpression(classes)) for (const entry of classes.elements) record(entry);
873
873
  }
@@ -22,22 +22,21 @@ export class ChatGateway {
22
22
  }
23
23
  ```
24
24
 
25
- Register it next to HTTP:
25
+ Declare it in its module, like a controller; `runApp` serves it on the HTTP listener:
26
26
 
27
27
  ```ts
28
- import { Module } from "@/core/di";
29
- import { httpModule } from "@/core/http";
30
- import { websocketModule } from "@/core/websocket";
31
-
32
- @Module({
33
- imports: [
34
- httpModule({ port: 3000 }),
35
- websocketModule({ gateways: [ChatGateway] }),
36
- ],
37
- })
38
- export class AppModule {}
28
+ import { Module, singleton } from "@/core/di";
29
+
30
+ @Module({ gateways: [ChatGateway], providers: [singleton(ChatHistory)] })
31
+ export class ChatModule {}
32
+
33
+ await runApp(AppModule, { http: { port: 3000 }, websocket: { requireAuth: true, authenticator } });
39
34
  ```
40
35
 
36
+ Without `runApp`, `websocketModule({ imports: [ChatModule], ... })` next to
37
+ `httpModule` does the same. Services send with `WebSocketService`:
38
+ `this.ws.to("user:ann").emit("order-paid", { id })`.
39
+
41
40
  The gateway above upgrades on `/ws/chat`.
42
41
 
43
42
  ## Handler Convention
@@ -102,7 +101,7 @@ default, `replayDelivery: "transport"`.
102
101
  ### Client-confirmed replay
103
102
 
104
103
  Opt in with `?replay=client-ack` on each connection, or require it for all clients
105
- with `websocketModule({ gateways, replayDelivery: "client-ack" })`. The adapter
104
+ with `runApp({ websocket: { replayDelivery: "client-ack" } })`. The adapter
106
105
  must support CAS. A saved client-ack session cannot reconnect in transport mode;
107
106
  such an upgrade returns 409. Invalid/duplicate replay query values return 400.
108
107
 
@@ -151,7 +150,7 @@ Pass an existing native Bun RedisClient from the host infrastructure:
151
150
  const adapter = new RedisWebSocketAdapter(hostRedisClient, {
152
151
  keyPrefix: "my-app:ws", // same on participating nodes; distinct per application
153
152
  });
154
- const module = websocketModule({ gateways, adapter, replayDelivery: "client-ack" });
153
+ await runApp(AppModule, { http: {}, websocket: { adapter, replayDelivery: "client-ack" } });
155
154
  ```
156
155
 
157
156
  The adapter owns only a duplicate connection for pub/sub. Closing the adapter
@@ -0,0 +1,47 @@
1
+ import type { WebSocketServer } from "./ws-server";
2
+ import type { SocketBroadcast } from "./types";
3
+ import type { ReliableBroadcastOptions, ReliableBroadcastReceipt } from "./reliable-delivery";
4
+
5
+ /** `to(room)` of one namespace, usable from any service. */
6
+ export interface WebSocketNamespace {
7
+ readonly name: string;
8
+ to(room: string): SocketBroadcast;
9
+ }
10
+
11
+ /**
12
+ * Sends events to rooms from outside a gateway handler: a service, an HTTP
13
+ * controller or a background job. Registered by `websocketModule` and exported
14
+ * globally, so any service can take it in its constructor.
15
+ *
16
+ * ```ts
17
+ * this.ws.to("user:ann").emit("order-paid", { id }); // one namespace
18
+ * this.ws.namespace("chat").to("general").emit("message", m); // a chosen one
19
+ * ```
20
+ */
21
+ export class WebSocketService {
22
+ public constructor(private readonly server: WebSocketServer) {}
23
+
24
+ /** The broadcaster of a namespace (`"chat"` and `"/chat"` are the same). */
25
+ public namespace(name: string): WebSocketNamespace {
26
+ const server = this.server;
27
+ return {
28
+ name,
29
+ to(room: string): SocketBroadcast {
30
+ return {
31
+ emit: (event, data) => server.broadcast(name, room, event, data),
32
+ emitReliable: (event, data, options: ReliableBroadcastOptions): Promise<ReliableBroadcastReceipt> =>
33
+ server.broadcastReliable(name, room, event, data, options),
34
+ };
35
+ },
36
+ };
37
+ }
38
+
39
+ /** `to(room)` of the only namespace; with several, name one with `namespace()`. */
40
+ public to(room: string): SocketBroadcast {
41
+ const names = this.server.namespaceNames;
42
+ if (names.length !== 1) {
43
+ throw new Error(`WebSocketService.to(room) needs exactly one namespace, found ${names.length} (${names.join(", ")}); use namespace(name).to(room).`);
44
+ }
45
+ return this.namespace(names[0] as string).to(room);
46
+ }
47
+ }
@@ -56,13 +56,13 @@ export class RedisRoomDeliveryStore implements ReliableRoomDelivery {
56
56
  if (!deliveryUuid.test(request.messageId) || !Number.isSafeInteger(request.expiresAt)
57
57
  || typeof request.namespace !== "string" || request.namespace.length > 256 || request.namespace.includes("\0")
58
58
  || typeof request.room !== "string" || !request.room.length || request.room.length > 256 || request.room.includes("\0")
59
- || typeof request.excludeSid !== "string" || request.excludeSid.length > 128
59
+ || (request.excludeSid !== undefined && (typeof request.excludeSid !== "string" || request.excludeSid.length > 128))
60
60
  || request.packet.type !== "event" || typeof request.packet.event !== "string" || !request.packet.event.length
61
61
  || request.packet.event.length > 256) throw new WebSocketDeliveryError("INVALID_PUBLICATION");
62
62
  const packet = JSON.stringify({ ...request.packet, v: 1, namespace: request.namespace, deliveryId: request.messageId });
63
63
  if (Buffer.byteLength(packet) > this.limits.maxQueueBytes) throw new WebSocketDeliveryError("QUEUE_CAPACITY");
64
64
  const metadata = JSON.stringify({ messageId: request.messageId, expiresAt: request.expiresAt,
65
- namespace: request.namespace, room: request.room, excludeSid: request.excludeSid });
65
+ namespace: request.namespace, room: request.room, excludeSid: request.excludeSid ?? "" });
66
66
  const fingerprint = canonicalJsonHashV1("bazis.websocket.publication/v1", { metadata: JSON.parse(metadata), packet: JSON.parse(packet) });
67
67
  const result = await this.operations.send("EVAL", [PUBLISH, "0", this.prefix, metadata, JSON.stringify(this.limits), fingerprint, packet]) as string[];
68
68
  if (result[0] !== "OK") throw new WebSocketDeliveryError(result[0] ?? "STORE_PROTOCOL");
@@ -48,7 +48,8 @@ export {
48
48
  type WebSocketServerStats,
49
49
  type WsConnectionData,
50
50
  } from "./ws-server";
51
- export { websocketModule, type WebSocketModuleConfig } from "./websocketModule";
51
+ export { websocketModule, type WebSocketModuleConfig, type WebSocketOptions } from "./websocketModule";
52
+ export { WebSocketService, type WebSocketNamespace } from "./WebSocketService";
52
53
  export { TopicCache, topicCache, type TopicCacheOptions, type TopicCacheStats } from "./topic-cache";
53
54
  export {
54
55
  encodeServerPacket,
@@ -1,4 +1,6 @@
1
1
  import type { SocketUser } from "./types";
2
+ import { JwtError } from "../../library/jwt";
3
+ import { UnauthorizedError } from "../http";
2
4
 
3
5
  const MAX_SOCKET_USER_ID_LENGTH = 256;
4
6
 
@@ -110,15 +112,24 @@ export function createWsAuthMiddleware(
110
112
  options?: { required?: boolean; allowQueryToken?: boolean },
111
113
  ): WsMiddleware {
112
114
  return async (ctx, next) => {
113
- const user = await authenticator(
114
- extractWebSocketToken(ctx.request, { allowQueryToken: options?.allowQueryToken }),
115
- {
116
- signal: ctx.signal,
117
- namespace: ctx.namespace,
118
- path: ctx.path,
119
- request: ctx.request,
120
- },
121
- );
115
+ let user: SocketUser | null;
116
+ try {
117
+ user = await authenticator(
118
+ extractWebSocketToken(ctx.request, { allowQueryToken: options?.allowQueryToken }),
119
+ {
120
+ signal: ctx.signal,
121
+ namespace: ctx.namespace,
122
+ path: ctx.path,
123
+ request: ctx.request,
124
+ },
125
+ );
126
+ } catch (error) {
127
+ // A bad or expired token is a refusal, not a server failure.
128
+ if (error instanceof JwtError || error instanceof UnauthorizedError) {
129
+ throw new WsUpgradeError("Unauthorized", 401);
130
+ }
131
+ throw error;
132
+ }
122
133
  if (user !== null && !isValidSocketUser(user)) {
123
134
  throw new WsUpgradeError("Unauthorized", 401);
124
135
  }
@@ -16,7 +16,8 @@ export interface ReliableBroadcastReceipt {
16
16
  export interface ReliableRoomPublication extends ReliableBroadcastOptions {
17
17
  readonly namespace: string;
18
18
  readonly room: string;
19
- readonly excludeSid: string;
19
+ /** The publishing session; omitted for a publication from a service. */
20
+ readonly excludeSid?: string;
20
21
  readonly packet: ServerPacket;
21
22
  }
22
23
 
@@ -1,14 +1,16 @@
1
1
  import {
2
2
  DI,
3
+ collectModuleGateways,
3
4
  markGlobal,
4
5
  Module,
5
- singleton,
6
- type Class,
7
6
  type BazisModule,
7
+ type BazisModuleRef,
8
8
  type ProviderDefinition,
9
9
  type ServiceResolver,
10
10
  } from "../di";
11
11
  import { WEBSOCKET_UPGRADE } from "../http";
12
+ import { LOGGER } from "../kernel";
13
+ import { WebSocketService } from "./WebSocketService";
12
14
  import { WebSocketExplorer } from "./explorer";
13
15
  import { WebSocketServer, type WebSocketMemoryLimits } from "./ws-server";
14
16
  import type { WsAccessTokenAuthenticator, WsMiddleware } from "./middleware";
@@ -19,11 +21,10 @@ import type { WebSocketDiagnostic } from "./reliable-delivery";
19
21
 
20
22
  export interface WebSocketModuleConfig {
21
23
  /**
22
- * Gateway classes decorated with `@WebSocketGateway`. Each is registered as a
23
- * singleton (with auto-resolved constructor dependencies) and explored for
24
- * `@SubscribeMessage` handlers.
24
+ * Modules whose `gateways` (classes with `@WebSocketGateway`) are served.
25
+ * The whole tree is searched, like `httpModule` searches for controllers.
25
26
  */
26
- readonly gateways: readonly Class<object>[];
27
+ readonly imports?: readonly BazisModuleRef[];
27
28
  /** Kernel-owned upgrade policy using DI; runs before gateway middleware. */
28
29
  readonly middlewareFactory?: (services: ServiceResolver) => WsMiddleware;
29
30
  /** Upgrade-time authentication; `null`/omitted means anonymous connections. */
@@ -47,29 +48,39 @@ export interface WebSocketModuleConfig {
47
48
  readonly exposeHandlerErrors?: boolean;
48
49
  }
49
50
 
51
+ /** Server-wide WebSocket settings of `runApp({ websocket })`; gateways come from the modules. */
52
+ export type WebSocketOptions = Omit<WebSocketModuleConfig, "imports">;
53
+
50
54
  /**
51
- * Registers WebSocket gateways and shares the HTTP listener for upgrades.
55
+ * Serves the gateways declared by the modules of `imports` on the shared HTTP
56
+ * listener. `runApp` installs it next to the HTTP module whenever a module
57
+ * declares `gateways`; `runApp({ websocket })` passes the settings.
52
58
  *
53
59
  * ```ts
54
- * @Module({ imports: [httpModule({ ... }), websocketModule({ gateways: [ChatGateway] })] })
55
- * class AppModule {}
60
+ * @Module({ gateways: [ChatGateway], providers: [singleton(ChatHistory)] })
61
+ * class ChatModule {}
56
62
  * ```
57
63
  *
58
64
  * The built `WebSocketServer` is exposed under {@link WEBSOCKET_UPGRADE}; the
59
65
  * HTTP server resolves it and wires `Bun.serve({ websocket })` automatically.
66
+ * `WebSocketService` is exported globally for sending from services.
60
67
  */
61
68
  export function websocketModule(config: WebSocketModuleConfig): BazisModule {
69
+ const gateways = collectModuleGateways(config.imports ?? []);
70
+ if (gateways.length === 0) {
71
+ throw new Error("websocketModule: no module of imports declares gateways. Add gateways: [ChatGateway] to the module that owns the gateway.");
72
+ }
62
73
  const providers: ProviderDefinition[] = [
63
- ...config.gateways.map((Gateway) => singleton(Gateway)),
64
74
  DI.singleton(
65
75
  DI.factoryProviderWithResolver(WEBSOCKET_UPGRADE, [], (resolver) => {
66
- const instances = config.gateways.map((Gateway) => resolver.resolve(Gateway));
76
+ const instances = gateways.map((Gateway) => resolver.resolve(Gateway));
67
77
  const namespaces = new WebSocketExplorer().explore(instances);
68
78
  if (config.middlewareFactory) {
69
79
  const middleware = config.middlewareFactory(resolver);
70
80
  if (typeof middleware !== "function") throw new Error("WebSocket middlewareFactory must return middleware.");
71
81
  for (const namespace of namespaces) namespace.middleware.unshift(middleware);
72
82
  }
83
+ const logger = resolver.tryResolve(LOGGER);
73
84
  return new WebSocketServer({
74
85
  namespaces,
75
86
  authenticator: config.authenticator ?? null,
@@ -82,14 +93,23 @@ export function websocketModule(config: WebSocketModuleConfig): BazisModule {
82
93
  ...(config.onDiagnostic ? { onDiagnostic: config.onDiagnostic } : {}),
83
94
  ...(config.limits ? { limits: config.limits } : {}),
84
95
  ...(config.exposeHandlerErrors !== undefined ? { exposeHandlerErrors: config.exposeHandlerErrors } : {}),
96
+ ...(logger ? { logger } : {}),
85
97
  });
86
98
  }),
87
99
  ),
100
+ DI.singleton(
101
+ DI.factoryProviderWithResolver(WebSocketService, [], (resolver) => {
102
+ const upgrade = resolver.resolve(WEBSOCKET_UPGRADE);
103
+ if (!(upgrade instanceof WebSocketServer)) throw new Error("WebSocketService needs the WebSocketServer registered by websocketModule.");
104
+ return new WebSocketService(upgrade);
105
+ }),
106
+ ),
88
107
  ];
89
108
 
90
109
  // Global + exported so the HTTP module can resolve the upgrade port without
91
- // importing the websocket module (one-way: websocket builds on http).
92
- @Module({ providers, exports: [WEBSOCKET_UPGRADE] })
110
+ // importing the websocket module (one-way: websocket builds on http), and
111
+ // any service can take WebSocketService.
112
+ @Module({ imports: config.imports, providers, exports: [WEBSOCKET_UPGRADE, WebSocketService] })
93
113
  class WebSocketModule {}
94
114
 
95
115
  return markGlobal(WebSocketModule);
@@ -40,7 +40,11 @@ import { InMemoryWebSocketAdapter } from "./adapter/in-memory.adapter";
40
40
  import type { PacketCodec } from "./codec/packet-codec.interface";
41
41
  import { payloadToBytes, payloadToText } from "./codec/packet-codec.interface";
42
42
  import { jsonPacketCodec } from "./codec/json.codec";
43
- import { WebSocketDeliveryError, type WebSocketDiagnostic, type ReliableSessionOwner } from "./reliable-delivery";
43
+ import { WebSocketDeliveryError, type WebSocketDiagnostic, type ReliableBroadcastOptions, type ReliableBroadcastReceipt, type ReliableSessionOwner } from "./reliable-delivery";
44
+ import type { Logger } from "../kernel";
45
+ import { redactSensitive } from "../../library/redaction";
46
+ import { normalizeNamespace } from "./decorators";
47
+ import type { OutgoingPacket } from "./socket-wrapper";
44
48
 
45
49
  export interface WebSocketMemoryLimits {
46
50
  readonly maxRateLimitBuckets?: number;
@@ -93,6 +97,8 @@ export interface WebSocketServerOptions {
93
97
  limits?: WebSocketMemoryLimits;
94
98
  /** Expose validator/handler exception messages. Disabled by default. */
95
99
  exposeHandlerErrors?: boolean;
100
+ /** Receives unexpected handler and upgrade failures (redacted). */
101
+ logger?: Logger;
96
102
  }
97
103
 
98
104
  export interface WebSocketServerStats {
@@ -352,10 +358,76 @@ export class WebSocketServer {
352
358
  if (handshakeController.signal.aborted && this.closed) {
353
359
  return new Response("WebSocket service unavailable", { status: 503 });
354
360
  }
361
+ this.options.logger?.error("WebSocket upgrade failed", { path: registered.path, error: redactSensitive(error) });
355
362
  return new Response("WebSocket upgrade failed", { status: 500 });
356
363
  }
357
364
  }
358
365
 
366
+ /** Namespaces served by this server, normalized (`/chat`). */
367
+ public get namespaceNames(): readonly string[] {
368
+ return [...this.namespacesByName.keys()];
369
+ }
370
+
371
+ /**
372
+ * Sends an event to every member of a room from outside a handler (a service,
373
+ * a controller, a background job). Delivery is the same as `socket.to(room)`:
374
+ * live members of this node and, through the adapter, of other nodes;
375
+ * offline members get it queued for reconnect replay.
376
+ */
377
+ public broadcast(namespace: string, room: string, event: string, data?: unknown): void {
378
+ const registered = this.requireNamespace(namespace);
379
+ this.publishToRoom(registered, room, { type: "event", namespace: registered.namespace, event, data }, outboundLimit(registered));
380
+ }
381
+
382
+ /** `socket.to(room).emitReliable` from outside a handler; needs the adapter capability. */
383
+ public async broadcastReliable(namespace: string, room: string, event: string, data: unknown, options: ReliableBroadcastOptions): Promise<ReliableBroadcastReceipt> {
384
+ const registered = this.requireNamespace(namespace);
385
+ const store = this.adapter.reliableRooms;
386
+ if (!store) throw new WebSocketDeliveryError("RELIABLE_DELIVERY_UNAVAILABLE");
387
+ const packet: OutgoingPacket = { type: "event", namespace: registered.namespace, event, data };
388
+ if (payloadBytes(this.codec.encodeServer({ ...packet, deliveryId: options.messageId })) > outboundLimit(registered)) {
389
+ throw new WebSocketDeliveryError("PAYLOAD_CAPACITY");
390
+ }
391
+ try {
392
+ const receipt = await store.publish({ ...options, namespace: registered.namespace, room, packet: { ...packet, v: 1 } });
393
+ this.diagnose("reliable-published", receipt.duplicate ? 0 : receipt.recipients);
394
+ return receipt;
395
+ } catch (error) {
396
+ this.diagnose("reliable-rejected");
397
+ throw error;
398
+ }
399
+ }
400
+
401
+ private requireNamespace(namespace: string): RegisteredNamespace {
402
+ if (this.closed || !this.initialized) throw new Error("WebSocket server is not running.");
403
+ const registered = this.namespacesByName.get(normalizeNamespace(namespace));
404
+ if (!registered) throw new Error(`Unknown WebSocket namespace "${namespace}"; registered: ${this.namespaceNames.join(", ")}.`);
405
+ return registered;
406
+ }
407
+
408
+ /**
409
+ * Fan-out to a room: the offline queue first (strict, so a rejection is not
410
+ * hidden behind a partial live delivery), then live members of this node,
411
+ * then other nodes through the adapter. `origin` is excluded, like `to()`.
412
+ */
413
+ private publishToRoom(registered: RegisteredNamespace, room: string, packet: OutgoingPacket, maxPayloadBytes: number, origin?: ServerWebSocket<WsConnectionData>): void {
414
+ const namespace = registered.namespace;
415
+ const topic = this.topics.get(namespace, room);
416
+ const payload = this.codec.encodeServer(packet);
417
+ const bytes = payloadToBytes(payload);
418
+ if (bytes.byteLength > maxPayloadBytes) {
419
+ throw new Error("WebSocket broadcast payload exceeds the namespace limit.");
420
+ }
421
+ const admission = this.sessionManager.enqueueToOfflineRoomMembers(namespace, room, { v: 1, ...packet }, origin?.data.sid, bytes.byteLength, true);
422
+ if (admission.truncated) {
423
+ this.diagnose("queue-rejected");
424
+ throw new WebSocketDeliveryError("OFFLINE_QUEUE_CAPACITY");
425
+ }
426
+ if (origin) origin.publish(topic, payload);
427
+ else this.bunServer?.publish(topic, this.codec.isBinary(payload) ? payload : payloadToText(payload));
428
+ this.trackAdapterOperation(this.adapter.publish(topic, bytes));
429
+ }
430
+
359
431
  private async performUpgrade(
360
432
  request: Request,
361
433
  server: Server<WsConnectionData>,
@@ -590,30 +662,10 @@ export class WebSocketServer {
590
662
  }
591
663
  },
592
664
  broadcast: (room, packet) => {
593
- if (signal?.aborted) {
665
+ if (signal?.aborted || !registered) {
594
666
  return;
595
667
  }
596
- const namespace = ws.data.namespace;
597
- const topic = this.topics.get(namespace, room);
598
- const payload = this.codec.encodeServer(packet);
599
- const bytes = payloadToBytes(payload);
600
- if (bytes.byteLength > maxPayloadBytes) {
601
- throw new Error("WebSocket broadcast payload exceeds the namespace limit.");
602
- }
603
- const admission = this.sessionManager.enqueueToOfflineRoomMembers(
604
- namespace,
605
- room,
606
- { v: 1, ...packet },
607
- ws.data.sid,
608
- bytes.byteLength,
609
- true,
610
- );
611
- if (admission.truncated) {
612
- this.diagnose("queue-rejected");
613
- throw new WebSocketDeliveryError("OFFLINE_QUEUE_CAPACITY");
614
- }
615
- ws.publish(topic, payload);
616
- this.trackAdapterOperation(this.adapter.publish(topic, bytes));
668
+ this.publishToRoom(registered, room, packet, maxPayloadBytes, ws);
617
669
  },
618
670
  broadcastReliable: async (room, packet, options) => {
619
671
  signal?.throwIfAborted();
@@ -1241,6 +1293,11 @@ export class WebSocketServer {
1241
1293
  const cause = error instanceof WsDispatchError ? error.cause : error;
1242
1294
  if (cause instanceof WebSocketDeliveryError) this.sendError(ws, cause.message, packet.id, cause.code);
1243
1295
  else this.sendError(ws, this.options.exposeHandlerErrors === true && rawMessage ? rawMessage : safeMessage, packet.id);
1296
+ // A validation failure is the client's mistake; anything else is ours and
1297
+ // belongs in the log, like an unhandled HTTP error.
1298
+ if (!(error instanceof WsDispatchError && error.kind === "validation") && !(cause instanceof WebSocketDeliveryError)) {
1299
+ this.options.logger?.error("WebSocket handler failed", { namespace: ws.data.namespace, event: packet.event, error: redactSensitive(cause) });
1300
+ }
1244
1301
  }
1245
1302
  throwIfAborted(signal);
1246
1303
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.30",
3
+ "version": "0.98.32",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",