@dunx/http 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,8 @@ import type { BunRequest } from 'bun';
2
2
  import { type App, type AppOptions, type Ctor, type InjectionToken, type ModuleRef, type ShutdownHookOptions, type ShutdownSignal } from '@dunx/core';
3
3
  import { type DiscoveredRoute } from '../route/discover.js';
4
4
  import type { WebSocketRuntime } from '../ws/adapter.js';
5
+ import type { SocketLoggingOptions } from '../ws/logging.js';
6
+ import type { SocketMiddleware } from '../ws/middleware.js';
5
7
  import type { PubSubRelay, RelayOptions } from '../ws/relay.js';
6
8
  import type { SocketOptions } from '../ws/socket.js';
7
9
  import type { CorsOptions } from './cors.js';
@@ -50,6 +52,29 @@ export interface HttpOptions extends AppOptions {
50
52
  * themselves are declared in `@Module({ providers })`.
51
53
  */
52
54
  readonly websocket?: SocketOptions;
55
+ /**
56
+ * The socket half of `middleware`, resolved from the container the same way.
57
+ *
58
+ * Each entry wraps every dispatched gateway handler - open, each named message,
59
+ * the catch-all, close, drain, ping and pong - the way an HTTP middleware wraps a
60
+ * route. `socketLogging`'s middleware runs outermost, ahead of anything here.
61
+ */
62
+ readonly socketMiddleware?: readonly Ctor<SocketMiddleware>[];
63
+ /**
64
+ * One structured entry per socket frame, on by default at **`debug`**. `false`
65
+ * removes it; an options object tunes the level per event. See
66
+ * {@link SocketLoggingMiddleware}.
67
+ *
68
+ * `debug` rather than request logging's `info`, because a gateway can take a
69
+ * frame per connection per tick. The default `ConsoleLogger` threshold is
70
+ * `info`, so this writes nothing until an app lowers its level or names a louder
71
+ * one here.
72
+ *
73
+ * Installing it also takes `SocketOptions.onError`'s `console.error` default out
74
+ * of the way: a middleware wraps the handler, so the failure is already reported
75
+ * through the `Logger` with the gateway and the event on it.
76
+ */
77
+ readonly socketLogging?: boolean | SocketLoggingOptions;
53
78
  /**
54
79
  * Multi-node websocket fan-out. Absent - the default - means `PubSub` publishes
55
80
  * to this process only, which is exactly Bun's native pub/sub and costs nothing.
@@ -138,6 +163,12 @@ export declare class HttpApplication implements HttpApp {
138
163
  * shutdown, which is what a readiness probe wants during a rolling deploy.
139
164
  */
140
165
  drain(): Promise<void>;
166
+ /**
167
+ * The four phases, in order, and **none of them is skipped because an earlier
168
+ * one failed**. A drain hook that threw used to abort this before `server.stop()`
169
+ * had run, so the port stayed open and `closed` never resolved; each failure is
170
+ * collected now and thrown once the whole teardown is over.
171
+ */
141
172
  shutdown(): Promise<void>;
142
173
  enableShutdownHooks(signals?: readonly ShutdownSignal[], options?: ShutdownHookOptions): this;
143
174
  }
@@ -1,8 +1,22 @@
1
1
  import { AppError, type Ctor, type Logger } from '@dunx/core';
2
+ export interface HttpErrorOptions extends ErrorOptions {
3
+ /**
4
+ * Headers the error response carries. `Retry-After` on a 429,
5
+ * `WWW-Authenticate` on a 401, `Allow` on a 405 - each of them part of the
6
+ * status rather than an extra, and none of them expressible by a throw before
7
+ * this existed.
8
+ *
9
+ * {@link errorMapper} copies them onto the response. An app that replaces the
10
+ * mapper has to read them itself, which is the same contract `status` and
11
+ * `message` already have.
12
+ */
13
+ readonly headers?: Readonly<Record<string, string>>;
14
+ }
2
15
  export declare class HttpError extends AppError {
3
16
  readonly status: number;
4
17
  name: string;
5
- constructor(status: number, message: string, options?: ErrorOptions);
18
+ readonly headers: Readonly<Record<string, string>> | undefined;
19
+ constructor(status: number, message: string, options?: HttpErrorOptions);
6
20
  }
7
21
  /** Which declared schema rejected the request. */
8
22
  export type InputSource = 'body' | 'query' | 'params';
@@ -2,6 +2,7 @@ import { type ModuleRef } from '@dunx/core';
2
2
  import { type HttpApp, type HttpOptions } from './application.js';
3
3
  export type { HttpApp, HttpOptions } from './application.js';
4
4
  export declare class HttpFactory {
5
+ #private;
5
6
  /**
6
7
  * Boots the container, discovers every controller's routes and every gateway's
7
8
  * handlers, and rejects a collision in either. The `Bun.serve` route table itself
@@ -0,0 +1,27 @@
1
+ export declare const REQUEST_ID_HEADER = "x-request-id";
2
+ /**
3
+ * The request id, and the only thing that decides a request has one.
4
+ *
5
+ * `RequestLoggingMiddleware` sets the header on a response it returns, and a
6
+ * failure is never one: `buildRoutes` and `buildFallback` catch outside the chain
7
+ * and build a fresh `Response` from the error mapper. So a guard's 401, a
8
+ * validation 400, a mapped 500 and every unmatched 404 went out with no id on
9
+ * them, which are the responses a caller most needs in order to find the log line
10
+ * the middleware just wrote.
11
+ *
12
+ * Recorded against the request rather than threaded through the mapper, because
13
+ * `ErrorMapper` is `(error, req) => Response` and an app writes its own.
14
+ * {@link stamp} then reads back whatever {@link assign} recorded, so an app that
15
+ * turned request logging off, or a path it told the middleware to ignore, is still
16
+ * answered without a header: nothing minted an id, so there is none to stamp.
17
+ */
18
+ export declare class RequestIds {
19
+ /**
20
+ * Called by `RequestLoggingMiddleware` and by nothing else. Splitting minting
21
+ * from recording would let a second caller invent an id the log line does not
22
+ * carry.
23
+ */
24
+ static assign(req: Request): string;
25
+ /** The response, with this request's id on it if it was ever given one. */
26
+ static stamp(response: Response, req: Request): Response;
27
+ }
@@ -2,7 +2,6 @@ import { Logger, RequestContext } from '@dunx/core';
2
2
  import type { BunRequest } from 'bun';
3
3
  import type { RouteContext } from './context.js';
4
4
  import type { Middleware, Next } from './middleware.js';
5
- export declare const REQUEST_ID_HEADER = "x-request-id";
6
5
  export interface RequestLoggingOptions {
7
6
  /** Bodies past this many characters are logged as a size. Default 2048. `0` omits them. */
8
7
  readonly maxBodyLength?: number;
@@ -47,6 +47,26 @@ export declare const withUpgradeRoutes: (routes: BunRoutes, gateways: ReadonlyMa
47
47
  * matched. It puts the global middleware in front of a 404 in the framework's
48
48
  * own error shape.
49
49
  *
50
+ * **The miss is a `throw`, not a returned `Response`.** `miss` raises
51
+ * `HttpError(404)` and `compose` propagates it, so a middleware written as
52
+ * `const response = await next(); if (response.status === 404) ...` never reaches
53
+ * its own second line on an unmatched path - the rewrite it was written for is the
54
+ * one case it cannot see. A middleware that means to act on a miss has to catch:
55
+ *
56
+ * ```ts
57
+ * try {
58
+ * return await next();
59
+ * } catch (error) {
60
+ * if (!(error instanceof HttpError) || error.status !== 404) throw error;
61
+ * return rewritten();
62
+ * }
63
+ * ```
64
+ *
65
+ * `ctx.get(UNMATCHED)` is the other half, and the cheaper one: it is set here and
66
+ * by no real route, so a middleware can tell "nothing matched this path" from "a
67
+ * handler answered 404 for a record that does not exist" **before** calling
68
+ * `next()` at all. Only the second of those is a `Response` to inspect.
69
+ *
50
70
  * Composed per request rather than at boot, because the context names the path
51
71
  * that missed. That allocation is on the 404 path only.
52
72
  */
@@ -14,9 +14,43 @@ import { type StaticOptionsInit } from './options.js';
14
14
  * default can know which. Anything outside the mount falls through untouched, so
15
15
  * the app's own routes and its 404 behave exactly as before.
16
16
  *
17
- * There is no `index.html` fallback and no SPA rewrite. Both are one route in the
18
- * app - `@Get('/*')` returning `Bun.file(...)` - and building them in would mean
17
+ * There is no `index.html` fallback and no SPA rewrite: building them in would mean
19
18
  * this middleware deciding what a 404 means for paths it does not own.
19
+ *
20
+ * An app that wants one writes a middleware **outside** this one, and the shape
21
+ * matters. An unmatched path is a **thrown** `HttpError(404)`, not a returned
22
+ * `Response` - see `buildFallback` - so reading `(await next()).status` never sees a
23
+ * miss, and `ctx.get(UNMATCHED)` is what does:
24
+ *
25
+ * ```ts
26
+ * export class SpaFallback implements Middleware {
27
+ * async handle(req: BunRequest, ctx: RouteContext, next: Next) {
28
+ * const missed = ctx.get(UNMATCHED) === true;
29
+ * if (
30
+ * !missed ||
31
+ * req.method !== 'GET' ||
32
+ * new URL(req.url).pathname.startsWith('/api') ||
33
+ * !(req.headers.get('accept') ?? '').includes('text/html')
34
+ * ) {
35
+ * return next();
36
+ * }
37
+ * const index = Bun.file(`${root}/index.html`);
38
+ * if (!(await index.exists())) return next();
39
+ * // The document carries the hashed asset names, so a stale one points at
40
+ * // bundles that no longer exist.
41
+ * return new Response(index, {
42
+ * headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' },
43
+ * });
44
+ * }
45
+ * }
46
+ * ```
47
+ *
48
+ * Two more things that shape where it goes in the chain. `notFound: 'guarded'` -
49
+ * the default - reports a miss with no route metadata, so a global session guard
50
+ * refuses it and the status is a 401 rather than a 404; an app serving a SPA wants
51
+ * `notFound: 'public'`. And the fallback answers **before** any middleware listed
52
+ * after the guard, so the rewrite has to sit ahead of the guard to see the miss at
53
+ * all.
20
54
  */
21
55
  export declare class StaticModule {
22
56
  static forRoot(init: StaticOptionsInit): DynamicModule;
@@ -0,0 +1,22 @@
1
+ import { type MetaKey } from '../route/metadata.js';
2
+ export interface ThrottleLimit {
3
+ /** Requests allowed per window, per subject. */
4
+ readonly limit: number;
5
+ readonly windowSeconds: number;
6
+ }
7
+ /**
8
+ * Read off a `RouteContext` the way `ROLES` and `PUBLIC` are, so an app can build
9
+ * its own guard on the same metadata rather than a parallel one.
10
+ */
11
+ export declare const THROTTLE: MetaKey<ThrottleLimit>;
12
+ export declare const SKIP_THROTTLE: MetaKey<boolean>;
13
+ /**
14
+ * A per-route limit, replacing the module's default for this handler.
15
+ *
16
+ * Valid on a method or on a class. A class-level limit covers every handler in the
17
+ * controller and a handler's own wins over it, because the route's metadata is
18
+ * `mergeMeta(klass, handler)` - the same precedence `@Roles` has.
19
+ */
20
+ export declare const Throttle: (limit: ThrottleLimit) => <F extends object>(target: F) => F;
21
+ /** Exempts a handler, or a whole controller, from the limit entirely. */
22
+ export declare const SkipThrottle: () => <F extends object>(target: F) => F;
@@ -0,0 +1,31 @@
1
+ import { Logger } from '@dunx/core';
2
+ import type { BunRequest } from 'bun';
3
+ import { ClientAddress } from '../server/client-address.js';
4
+ import type { RouteContext } from '../server/context.js';
5
+ import type { Middleware, Next } from '../server/middleware.js';
6
+ import { ThrottleOptions } from './options.js';
7
+ import { ThrottleStore } from './store.js';
8
+ /**
9
+ * A fixed-window rate limit, one key per subject and handler.
10
+ *
11
+ * **Fails open.** A store that cannot be reached allows the request and warns
12
+ * **once per process** - a line per request would be its own outage, and refusing
13
+ * every request because the counter is down turns a degraded dependency into a
14
+ * dead service.
15
+ *
16
+ * **List it after any session guard.** An authenticated caller should be limited by
17
+ * user id and an anonymous one by address, and only the guard ahead of this one
18
+ * knows which - which is what `ThrottleOptions.subject` reads.
19
+ *
20
+ * The 429 is thrown, never returned, so it goes through the app's own `onError` and
21
+ * comes out in the app's error shape like every other status.
22
+ */
23
+ export declare class ThrottleGuard implements Middleware {
24
+ #private;
25
+ private readonly options;
26
+ private readonly store;
27
+ private readonly address;
28
+ private readonly logger;
29
+ constructor(options: ThrottleOptions, store: ThrottleStore, address: ClientAddress, logger: Logger);
30
+ handle(req: BunRequest, ctx: RouteContext, next: Next): Promise<Response>;
31
+ }
@@ -0,0 +1,35 @@
1
+ import { type Deps, type DynamicModule, type FactoryProvider } from '@dunx/core';
2
+ import { type ThrottleOptionsInit } from './options.js';
3
+ /**
4
+ * A first-class rate limit: the decorator, the guard, the counter and its options.
5
+ *
6
+ * `global: true`, because the guard is listed in `HttpOptions.middleware` - which
7
+ * is the app's own list, resolved from wherever the class is declared - and a
8
+ * non-global module would make every consumer import this one to reach a guard it
9
+ * never names.
10
+ *
11
+ * ```ts
12
+ * ThrottleModule.forRootAsync({
13
+ * useFactory: (config: AppConfig, redis: RedisConnection) => ({
14
+ * ...config.throttle,
15
+ * prefix: config.app.name,
16
+ * store: new RedisThrottleStore(redis),
17
+ * subject: (req) => caller.optional()?.id ?? address.of(req),
18
+ * }),
19
+ * inject: [AppConfig, RedisConnection] as const,
20
+ * });
21
+ *
22
+ * HttpFactory.create(AppModule, { middleware: [SessionGuard, ThrottleGuard] });
23
+ * ```
24
+ *
25
+ * Position in the chain is the app's, the same decision `StaticFiles` leaves open,
26
+ * and for a sharper reason: ahead of a session guard the limit counts every caller
27
+ * as an address.
28
+ */
29
+ export declare class ThrottleModule {
30
+ static forRoot(init: ThrottleOptionsInit): DynamicModule;
31
+ /** `forRoot` with the limit read off the container - a config value, usually. */
32
+ static forRootAsync<const D extends Deps>(config: FactoryProvider<ThrottleOptionsInit, D> & {
33
+ readonly imports?: DynamicModule['imports'];
34
+ }): DynamicModule;
35
+ }
@@ -0,0 +1,52 @@
1
+ import type { BunRequest } from 'bun';
2
+ import type { RouteContext } from '../server/context.js';
3
+ import type { ThrottleLimit } from './decorators.js';
4
+ import type { ThrottleStore } from './store.js';
5
+ export interface ThrottleOptionsInit extends ThrottleLimit {
6
+ /**
7
+ * Namespaces every key this app writes. **Required, and an empty one throws.**
8
+ *
9
+ * There is no default on purpose. A scaffolded app that inherits the template's
10
+ * prefix and ships with it puts two applications in one Redis on one throttle
11
+ * namespace, each spending the other's budget - which is exactly what happened,
12
+ * and a friendly fallback is what let it.
13
+ */
14
+ readonly prefix: string;
15
+ /**
16
+ * Who is being limited. Defaults to the client address, or `'anonymous'` when
17
+ * even that is unknown.
18
+ *
19
+ * This is an option rather than an injected caller because the identity a limit
20
+ * counts by belongs to the app: an authenticated request is limited by user id
21
+ * and an anonymous one by address, and only the guard ahead of this one knows
22
+ * which. It is also what keeps `@dunx/http` from depending on `@dunx/auth`.
23
+ *
24
+ * ```ts
25
+ * subject: (req) => currentUser.optional()?.id ?? address.of(req)
26
+ * ```
27
+ */
28
+ readonly subject?: (req: BunRequest, ctx: RouteContext) => string | undefined;
29
+ /**
30
+ * Send `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, plus
31
+ * `Retry-After` on a 429. @default true
32
+ */
33
+ readonly headers?: boolean;
34
+ /**
35
+ * The counter. Defaults to {@link MemoryThrottleStore}, which is per process -
36
+ * so two replicas each allow the full budget until this names a shared one.
37
+ */
38
+ readonly store?: ThrottleStore;
39
+ }
40
+ /**
41
+ * A class, not an interface, so it is a runtime value and can be a constructor
42
+ * parameter type the transform records - the same reason `StaticOptions` is one.
43
+ */
44
+ export declare class ThrottleOptions {
45
+ readonly limit: number;
46
+ readonly windowSeconds: number;
47
+ readonly prefix: string;
48
+ readonly headers: boolean;
49
+ readonly subject: ((req: BunRequest, ctx: RouteContext) => string | undefined) | undefined;
50
+ readonly store: ThrottleStore | undefined;
51
+ constructor(init: ThrottleOptionsInit);
52
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The counter behind the guard.
3
+ *
4
+ * An `abstract class` rather than an interface: `@dunx/transform` records
5
+ * constructor parameter *types*, so an interface at an injection site is a boot
6
+ * error. Same reason `RedisConnection` and `Logger` are classes.
7
+ *
8
+ * **A fixed window, not a sliding one.** `hit` increments and returns the count for
9
+ * the window the key is already in; the window starts at the first hit and ends
10
+ * when the key expires. A sliding window needs a sorted set per subject and a
11
+ * range trim per request, which is a different cost for an accuracy a rate limit
12
+ * does not need.
13
+ */
14
+ export declare abstract class ThrottleStore {
15
+ constructor();
16
+ /**
17
+ * The count for this key in the current window.
18
+ *
19
+ * **`undefined` means the store could not be reached**, and the guard reads that
20
+ * as "allow". A rate limiter that turns an unreachable Redis into a 503 has
21
+ * turned a degraded route into an outage.
22
+ */
23
+ abstract hit(key: string, windowSeconds: number): Promise<number | undefined>;
24
+ /** Seconds left in this key's window, for `Retry-After`. */
25
+ abstract ttl(key: string): Promise<number | undefined>;
26
+ }
27
+ /**
28
+ * The commands the Redis store needs, restated structurally so this package keeps
29
+ * its zero dependencies - the same trick `PubSubRelay` uses.
30
+ *
31
+ * `@dunx/infra`'s `RedisConnection` satisfies it, and so does a bare
32
+ * `Bun.RedisClient`, without either being named here.
33
+ */
34
+ export interface ThrottleRedis {
35
+ incr(key: string): Promise<number>;
36
+ expire(key: string, seconds: number): Promise<unknown>;
37
+ ttl(key: string): Promise<number>;
38
+ }
39
+ /**
40
+ * The multi-process counter: one key per subject and handler.
41
+ *
42
+ * `INCR` then `EXPIRE`, and the `EXPIRE` **only on the call that returned 1**. That
43
+ * is what makes the window start at the first hit rather than being pushed forward
44
+ * by every subsequent one, and it is two round trips rather than a Lua script
45
+ * because `Bun.RedisClient` pipelines on its own.
46
+ */
47
+ export declare class RedisThrottleStore extends ThrottleStore {
48
+ private readonly redis;
49
+ constructor(redis: ThrottleRedis);
50
+ hit(key: string, windowSeconds: number): Promise<number | undefined>;
51
+ ttl(key: string): Promise<number | undefined>;
52
+ }
53
+ /**
54
+ * The single-process counter, and the default - so an app with no Redis still
55
+ * limits something rather than nothing.
56
+ *
57
+ * It is per process, which is the whole caveat: two replicas each allow the full
58
+ * budget. `RedisThrottleStore` is the answer for more than one.
59
+ *
60
+ * The map is bounded. An expired entry is dropped when its key is next touched,
61
+ * and once the map passes `maxKeys` every expired entry is swept - so a burst
62
+ * across many subjects cannot grow it without limit. Reaching the cap with nothing
63
+ * expired clears it, which resets a window early rather than holding memory a
64
+ * server does not have.
65
+ */
66
+ export declare class MemoryThrottleStore extends ThrottleStore {
67
+ #private;
68
+ constructor(maxKeys?: number);
69
+ hit(key: string, windowSeconds: number): Promise<number | undefined>;
70
+ ttl(key: string): Promise<number | undefined>;
71
+ }
@@ -1,5 +1,6 @@
1
1
  import type { BunRequest, Server, WebSocketHandler } from 'bun';
2
2
  import type { DiscoveredGateway } from './discover.js';
3
+ import { type SocketMiddleware } from './middleware.js';
3
4
  import type { SocketData, SocketOptions } from './socket.js';
4
5
  /**
5
6
  * A gateway's entry in the server's route table. Returning `undefined` is how Bun
@@ -24,6 +25,11 @@ export interface WebSocketRuntime {
24
25
  * "Consuming N job(s)" entry already set.
25
26
  */
26
27
  readonly gateways: readonly GatewaySummary[];
28
+ /**
29
+ * What `HttpFactory.create` logs at boot, the way the container logs its own
30
+ * scope warnings. Empty for a server that reports socket errors, or says it does.
31
+ */
32
+ readonly warnings: readonly string[];
27
33
  }
28
34
  export interface GatewaySummary {
29
35
  readonly name: string;
@@ -31,4 +37,4 @@ export interface GatewaySummary {
31
37
  /** `@OnMessage('name')` events. A raw catch-all has no name to report. */
32
38
  readonly events: readonly string[];
33
39
  }
34
- export declare const buildWebSocket: (discovered: readonly DiscoveredGateway[], options?: SocketOptions) => WebSocketRuntime;
40
+ export declare const buildWebSocket: (discovered: readonly DiscoveredGateway[], options?: SocketOptions, middleware?: readonly SocketMiddleware[]) => WebSocketRuntime;
@@ -0,0 +1,69 @@
1
+ import { Logger, LogLevel, RequestContext } from '@dunx/core';
2
+ import { type SocketContext, type SocketFrame, type SocketMiddleware, type SocketNext } from './middleware.js';
3
+ export interface SocketLoggingOptions {
4
+ /**
5
+ * The level every message frame is logged at. Default **`'debug'`**.
6
+ *
7
+ * `'debug'`, not `'info'`, because a socket is not a request: a gateway can take
8
+ * a frame per player per tick, and one `info` line each would bury everything
9
+ * else the process writes. The default `ConsoleLogger` threshold is `'info'`, so
10
+ * this is off until an app lowers its level or names a louder one here.
11
+ */
12
+ readonly level?: LogLevel;
13
+ /** What a throwing or rejecting handler is logged at. @default 'error' */
14
+ readonly errorLevel?: LogLevel;
15
+ /**
16
+ * Per-event level, keyed by the `@OnMessage(event)` name. `false` skips the
17
+ * event entirely, and skipping is complete: no entry, no timing, no scope.
18
+ *
19
+ * ```ts
20
+ * socketLogging: { events: { placeBet: 'info', cursorMove: false } }
21
+ * ```
22
+ */
23
+ readonly events?: Readonly<Record<string, LogLevel | false>>;
24
+ /**
25
+ * Open, close, drain, ping and pong. Defaults to `level`; `false` drops them.
26
+ *
27
+ * Separate from `level` because the two answer different questions - how much
28
+ * traffic a socket carries, against how many sockets there are - and an app that
29
+ * silences the first usually still wants the second.
30
+ */
31
+ readonly lifecycle?: LogLevel | false;
32
+ /**
33
+ * Log the frame's payload. Default **`false`**.
34
+ *
35
+ * A payload is caller-supplied and arrives without validation, so it is both the
36
+ * field most likely to carry a credential and the one most likely to be large.
37
+ */
38
+ readonly payload?: boolean;
39
+ /** Payloads past this many characters are logged as a size. @default 512 */
40
+ readonly maxPayloadLength?: number;
41
+ /**
42
+ * Wrap each dispatch in an `AsyncRequestContext` scope. Default **`true`**.
43
+ *
44
+ * The scope is what makes a line a service logs four frames down carry
45
+ * `connectionId` and `event` without being handed the socket.
46
+ */
47
+ readonly correlate?: boolean;
48
+ }
49
+ /**
50
+ * One structured entry per dispatched frame, carrying the frame and its outcome.
51
+ *
52
+ * The socket counterpart of `RequestLoggingMiddleware`, and the same single-entry
53
+ * shape: the middleware wraps the handler, so the frame and what it answered are
54
+ * one line rather than an inbound line to correlate with an outbound one.
55
+ *
56
+ * It also replaces what a gateway would otherwise hand-write. A throwing handler
57
+ * reaches the `Logger` here with the gateway, the path and the event on it -
58
+ * `SocketOptions.onError`'s default is a bare `console.error` off the logging
59
+ * pipeline entirely, and installing this takes that fallback out of the way.
60
+ */
61
+ export declare class SocketLoggingMiddleware implements SocketMiddleware {
62
+ #private;
63
+ private readonly logger;
64
+ private readonly context;
65
+ /** A throwing handler reaches the `Logger` here, at `errorLevel`. */
66
+ readonly reportsErrors = true;
67
+ constructor(logger: Logger, context: RequestContext, options?: SocketLoggingOptions);
68
+ handle(frame: SocketFrame, ctx: SocketContext, next: SocketNext): unknown;
69
+ }
@@ -0,0 +1,95 @@
1
+ import type { HandlerKind } from './marker.js';
2
+ import type { Socket } from './socket.js';
3
+ /**
4
+ * Which handler a frame is on its way to, resolved at boot.
5
+ *
6
+ * The websocket half of {@link RouteContext}: it names the gateway rather than the
7
+ * controller, and the envelope event rather than the method and path. One object
8
+ * per slot, built once, so a middleware costs no allocation per frame beyond the
9
+ * frame itself.
10
+ */
11
+ export interface SocketContext {
12
+ /** The gateway class's name. */
13
+ readonly gateway: string;
14
+ /** The path it upgraded on, exactly as mounted. */
15
+ readonly path: string;
16
+ readonly kind: HandlerKind;
17
+ /**
18
+ * The `@OnMessage(event)` name. `undefined` for a lifecycle hook and for the raw
19
+ * `@OnMessage()` catch-all, which claims every frame no named handler took.
20
+ */
21
+ readonly event: string | undefined;
22
+ }
23
+ /**
24
+ * The frame itself: the socket it arrived on, and the argument the handler is
25
+ * about to be given.
26
+ *
27
+ * `data` is the envelope's `data` for a named message, the whole frame for the raw
28
+ * catch-all, `{ code, reason }` for a close, the buffer for a ping or a pong, and
29
+ * `undefined` for an open or a drain.
30
+ */
31
+ export interface SocketFrame {
32
+ readonly socket: Socket;
33
+ readonly data: unknown;
34
+ }
35
+ /**
36
+ * Runs the rest of the chain and finally the gateway handler, returning whatever
37
+ * it returned - which for a named message is the value dunx sends back.
38
+ *
39
+ * It is **not** `Promise<unknown>`, unlike the HTTP `Next`. A gateway handler may
40
+ * be synchronous and the dispatcher does not allocate a promise to hide that, so a
41
+ * middleware that needs the outcome handles both channels. {@link observe} is that
42
+ * dance, written once.
43
+ */
44
+ export type SocketNext = () => unknown;
45
+ /**
46
+ * The single extension point on the socket side, shaped like {@link Middleware} on
47
+ * the HTTP side: one method, wrapping `next()`.
48
+ *
49
+ * It sees every dispatched handler - open, each named message, the catch-all,
50
+ * close, drain, ping and pong - and open and close arrive even for a gateway that
51
+ * declares no `@OnOpen`/`@OnClose`, so a connection is never invisible to it.
52
+ *
53
+ * A throwing or rejecting handler passes through here, which is where a guard
54
+ * refuses and where an observer records the failure. Rethrow to leave the outcome
55
+ * to `SocketOptions.onError`; return a value instead to answer the frame.
56
+ *
57
+ * Three things it cannot see, because they never reach the dispatcher: a
58
+ * `socket.send` a handler makes itself, a `PubSub` broadcast, and the upgrade -
59
+ * which is an HTTP request answered by the gateway's own route.
60
+ */
61
+ export interface SocketMiddleware {
62
+ /**
63
+ * That a failure passing through here is reported somewhere. Default
64
+ * **`false`**.
65
+ *
66
+ * `SocketOptions.onError`'s `console.error` fallback is not installed while any
67
+ * socket middleware exists, because a middleware wraps the handler and would
68
+ * report the same failure a second time. Whether it does is something only the
69
+ * middleware knows: one that ignores a throw turns error reporting off for the
70
+ * whole server, and nothing about the wiring says so.
71
+ *
72
+ * Setting it is how a middleware says it does report. Leaving it unset with no
73
+ * `websocket.onError` beside it is what `HttpFactory.create` warns about at
74
+ * boot.
75
+ */
76
+ readonly reportsErrors?: boolean;
77
+ handle(frame: SocketFrame, ctx: SocketContext, next: SocketNext): unknown;
78
+ }
79
+ /** One slot's folded chain. The handler's own arguments ride in `run`. */
80
+ export type SocketDispatch = (frame: SocketFrame, run: SocketNext) => unknown;
81
+ /**
82
+ * Folded into one closure per slot at boot, the same shape `compose` gives an HTTP
83
+ * route - so dispatch stays a property read and a call, with no array iteration
84
+ * per frame.
85
+ */
86
+ export declare const composeSocket: (middleware: readonly SocketMiddleware[], ctx: SocketContext) => SocketDispatch;
87
+ /**
88
+ * Calls `next()` and reports how it went, on whichever channel it went out on,
89
+ * leaving the result untouched.
90
+ *
91
+ * `error` is `undefined` on success. A synchronous throw and a rejection both
92
+ * reach `done` and are then rethrown, so a middleware that only observes cannot
93
+ * accidentally swallow a failure.
94
+ */
95
+ export declare const observe: (next: SocketNext, done: (error: unknown, value: unknown) => void) => unknown;
@@ -7,6 +7,12 @@ import type { ServerWebSocket, WebSocketHandler } from 'bun';
7
7
  export interface SocketData<T = unknown> {
8
8
  readonly path: string;
9
9
  readonly context: T;
10
+ /**
11
+ * This connection, for as long as it lasts. Minted at the upgrade, because Bun's
12
+ * socket carries no identity of its own and a log line for a frame is only
13
+ * joinable to the connect and the disconnect around it if something does.
14
+ */
15
+ readonly id: string;
10
16
  }
11
17
  /**
12
18
  * Bun's native socket, unwrapped. `send`, `subscribe`, `unsubscribe`,
@@ -23,6 +29,17 @@ export type SocketErrorHandler = (error: unknown, socket: Socket) => void;
23
29
  * anything above 960 at `Bun.serve` time.
24
30
  */
25
31
  export type SocketOptions = Readonly<Pick<WebSocketHandler<SocketData>, 'backpressureLimit' | 'closeOnBackpressureLimit' | 'idleTimeout' | 'maxPayloadLength' | 'perMessageDeflate' | 'publishToSelf' | 'sendPings'>> & {
26
- /** Where a throwing or rejecting handler goes. @default console.error */
32
+ /**
33
+ * Where a throwing or rejecting handler goes. @default console.error
34
+ *
35
+ * The default is **not** installed when `socketMiddleware` is non-empty: a
36
+ * middleware wraps the handler, so it already saw the failure and a second
37
+ * report on the console would be a duplicate.
38
+ *
39
+ * Seeing a failure and reporting it are not the same thing, so a middleware that
40
+ * reports says so with `SocketMiddleware.reportsErrors`. Middleware that sets it
41
+ * nowhere, and no `onError` here, is a boot warning: the fallback is gone and
42
+ * nothing replaced it.
43
+ */
27
44
  readonly onError?: SocketErrorHandler;
28
45
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/http",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
4
4
  "description": "Bun.serve adapter for the dunx framework: controllers, middleware and WebSocket gateways",
5
5
  "keywords": [
6
6
  "bun",
@@ -58,7 +58,7 @@
58
58
  "@dunx/core": "workspace:*"
59
59
  },
60
60
  "peerDependencies": {
61
- "@dunx/core": "^2.1.1",
61
+ "@dunx/core": "^2.2.1",
62
62
  "@types/bun": ">=1.3.0"
63
63
  },
64
64
  "peerDependenciesMeta": {