@dunx/http 0.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.
@@ -0,0 +1,84 @@
1
+ import type { BunRequest } from 'bun';
2
+ import { type App, type AppOptions, type Ctor, type InjectionToken, type ShutdownSignal } from '@dunx/core';
3
+ import { type DiscoveredRoute } from '../route/discover.js';
4
+ import type { WebSocketRuntime } from '../ws/adapter.js';
5
+ import type { PubSubRelay } from '../ws/relay.js';
6
+ import type { SocketOptions } from '../ws/socket.js';
7
+ import type { CorsOptions } from './cors.js';
8
+ import { type ErrorMapper } from './errors.js';
9
+ import type { Middleware } from './middleware.js';
10
+ import { type RequestLoggingOptions } from './request-logging.js';
11
+ import { type AppSettings } from './settings.js';
12
+ export interface HttpOptions extends AppOptions {
13
+ readonly port?: number;
14
+ /** Resolved from the container, so middleware can inject(). */
15
+ readonly middleware?: readonly Ctor<Middleware>[];
16
+ readonly onError?: ErrorMapper;
17
+ /**
18
+ * One structured entry per request, on by default. `false` removes it; an
19
+ * options object tunes what it records. See {@link RequestLoggingMiddleware}.
20
+ *
21
+ * It is the **outermost** middleware, ahead of anything `middleware` declares,
22
+ * so a request rejected by a guard is still logged with the status it got.
23
+ */
24
+ readonly requestLogging?: boolean | RequestLoggingOptions;
25
+ /**
26
+ * Bun's `websocket` options, plus where a throwing handler goes. Server-wide, so
27
+ * they live here next to `middleware` rather than on a module: gateways
28
+ * themselves are declared in `@Module({ providers })`.
29
+ */
30
+ readonly websocket?: SocketOptions;
31
+ /**
32
+ * Multi-node websocket fan-out. Absent — the default — means `PubSub` publishes
33
+ * to this process only, which is exactly Bun's native pub/sub and costs nothing.
34
+ *
35
+ * `new RedisRelay({ url })` is the batteries-included one. Anything with a
36
+ * `publish` and a `subscribe` fits, including `@dunx/infra`'s `RedisConnection`,
37
+ * which has to come out of the container and so goes through
38
+ * `app.get(PubSub).relayThrough(...)` instead of this option.
39
+ */
40
+ readonly relay?: PubSubRelay;
41
+ /** The broker channel the relay carries frames on. @default 'dunx:ws' */
42
+ readonly relayChannel?: string;
43
+ }
44
+ /**
45
+ * Everything below `listen()` configures the route table, which is built exactly
46
+ * once — when the server binds. Calling any of them afterwards throws rather than
47
+ * being quietly dropped.
48
+ */
49
+ export interface HttpApp extends App {
50
+ /** Prefixes every discovered route. Last call wins. */
51
+ setGlobalPrefix(prefix: string): this;
52
+ /** Appends middleware, after anything `HttpOptions.middleware` declared. */
53
+ use(...middleware: readonly Ctor<Middleware>[]): this;
54
+ set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): this;
55
+ setting<K extends keyof AppSettings>(key: K): AppSettings[K];
56
+ /** Mounts an `OPTIONS` preflight per path. Last call wins. */
57
+ enableCors(options?: CorsOptions): this;
58
+ /** The same `inject(ClientAddress)` singleton — honours `'trust proxy'`. */
59
+ clientIp(req: BunRequest): string | undefined;
60
+ /** Every gateway path this app upgrades on, exactly as mounted. */
61
+ readonly gatewayPaths: readonly string[];
62
+ listen(port?: number): Promise<string>;
63
+ }
64
+ export declare class HttpApplication implements HttpApp {
65
+ #private;
66
+ readonly closed: Promise<void>;
67
+ readonly gatewayPaths: readonly string[];
68
+ constructor(app: App, discovered: readonly DiscoveredRoute[], options: HttpOptions, websocket?: WebSocketRuntime);
69
+ get<T>(token: InjectionToken<T>): T;
70
+ setGlobalPrefix(prefix: string): this;
71
+ use(...middleware: readonly Ctor<Middleware>[]): this;
72
+ set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): this;
73
+ setting<K extends keyof AppSettings>(key: K): AppSettings[K];
74
+ enableCors(options?: CorsOptions): this;
75
+ clientIp(req: BunRequest): string | undefined;
76
+ /**
77
+ * The one `Bun.serve` call. A gateway's upgrade is a native `GET` route in the
78
+ * same table, so Bun's router — not a hand-written `fetch` fallback — is what
79
+ * matches an upgrade, and no `fetch` handler is needed at all.
80
+ */
81
+ listen(port?: number): Promise<string>;
82
+ shutdown(): Promise<void>;
83
+ enableShutdownHooks(signals?: readonly ShutdownSignal[]): this;
84
+ }
@@ -0,0 +1,15 @@
1
+ import type { BunRequest, Server } from 'bun';
2
+ export interface AddressSource {
3
+ readonly server: Server<unknown>;
4
+ readonly trustProxy: boolean;
5
+ }
6
+ /**
7
+ * The client's address, honouring the `'trust proxy'` setting. Every class is
8
+ * injectable, so `inject(ClientAddress)` in a middleware or controller needs no
9
+ * registration; `app.clientIp(req)` is the same instance.
10
+ */
11
+ export declare class ClientAddress {
12
+ of(req: BunRequest): string | undefined;
13
+ }
14
+ /** Internal: `listen()` hands the bound server to the resolved singleton. */
15
+ export declare const attachAddressSource: (target: ClientAddress, source: AddressSource) => void;
@@ -0,0 +1,21 @@
1
+ import type { DiscoveredRoute } from '../route/discover.js';
2
+ import type { HttpMethod } from '../route/marker.js';
3
+ import type { MetaKey } from '../route/metadata.js';
4
+ /**
5
+ * Which route the middleware is running for, and what that route's decorators
6
+ * declared. `get` resolves the handler's metadata first and the controller class's
7
+ * second — the same override direction as Nest's `getAllAndOverride`.
8
+ */
9
+ export interface RouteContext {
10
+ readonly controller: string;
11
+ readonly handler: string;
12
+ readonly method: HttpMethod;
13
+ readonly path: string;
14
+ get<T>(key: MetaKey<T>): T | undefined;
15
+ }
16
+ /**
17
+ * One frozen context per route, built when the table is built and closed over by
18
+ * the chain. The merge already happened at discovery, so `get` is a Map lookup —
19
+ * not a prototype walk, and nothing is read per request.
20
+ */
21
+ export declare const buildContext: (route: DiscoveredRoute) => RouteContext;
@@ -0,0 +1,26 @@
1
+ import type { RouteHandler } from './middleware.js';
2
+ export type CorsOrigin = string | readonly string[] | ((origin: string) => boolean);
3
+ export interface CorsOptions {
4
+ /**
5
+ * `'*'` by default. A concrete string, a list, or a predicate all answer with the
6
+ * caller's own origin only when it is allowed — a request from anywhere else gets
7
+ * no CORS headers at all, which is what makes the browser block it.
8
+ */
9
+ readonly origin?: CorsOrigin;
10
+ /** Defaults to the methods actually declared on the path. */
11
+ readonly methods?: readonly string[];
12
+ /** Echoes `Access-Control-Request-Headers` when omitted. */
13
+ readonly allowedHeaders?: readonly string[];
14
+ readonly exposedHeaders?: readonly string[];
15
+ readonly credentials?: boolean;
16
+ /** Seconds a browser may cache the preflight for. */
17
+ readonly maxAge?: number;
18
+ }
19
+ /** Adds the response-side CORS headers. One extra closure per route, at boot. */
20
+ export declare const withCors: (options: CorsOptions, handler: RouteHandler) => RouteHandler;
21
+ /**
22
+ * `Bun.serve({ routes })` answers a method miss with 404, so a preflight cannot be
23
+ * inferred — every CORS-enabled path gets its own `OPTIONS` handler, built at boot
24
+ * from the methods that path actually declares.
25
+ */
26
+ export declare const preflight: (options: CorsOptions, methods: readonly string[]) => RouteHandler;
@@ -0,0 +1,25 @@
1
+ import { AppError } from '@dunx/core';
2
+ export declare class HttpError extends AppError {
3
+ readonly status: number;
4
+ name: string;
5
+ constructor(status: number, message: string, options?: ErrorOptions);
6
+ }
7
+ /** Which declared schema rejected the request. */
8
+ export type InputSource = 'body' | 'query' | 'params';
9
+ /** A Standard Schema issue, flattened: `path` is dotted, or absent at the root. */
10
+ export interface ValidationIssue {
11
+ readonly message: string;
12
+ readonly path?: string;
13
+ }
14
+ /**
15
+ * A declared schema rejected the input. Always a 400, and the issues survive into
16
+ * the response body — a caller cannot fix what it cannot see.
17
+ */
18
+ export declare class ValidationError extends HttpError {
19
+ readonly source: InputSource;
20
+ readonly issues: readonly ValidationIssue[];
21
+ name: string;
22
+ constructor(source: InputSource, issues: readonly ValidationIssue[]);
23
+ }
24
+ export type ErrorMapper = (error: unknown, req: Request) => Response;
25
+ export declare const defaultErrorMapper: ErrorMapper;
@@ -0,0 +1,12 @@
1
+ import { type ModuleRef } from '@dunx/core';
2
+ import { type HttpApp, type HttpOptions } from './application.js';
3
+ export type { HttpApp, HttpOptions } from './application.js';
4
+ export declare class HttpFactory {
5
+ /**
6
+ * Boots the container, discovers every controller's routes and every gateway's
7
+ * handlers, and rejects a collision in either. The `Bun.serve` route table itself
8
+ * is built by `listen()`, so `setGlobalPrefix`, `use`, `set` and `enableCors` can
9
+ * still affect it.
10
+ */
11
+ static create(root: ModuleRef, options?: HttpOptions): Promise<HttpApp>;
12
+ }
@@ -0,0 +1,17 @@
1
+ import type { BunRequest } from 'bun';
2
+ import type { RouteInput, RouteSchemas } from '../route/schema.js';
3
+ /**
4
+ * Built once per route at boot. A route that declares nothing gets the identity
5
+ * reader — no parse, no validation, not even a promise.
6
+ *
7
+ * A reader **returns a promise only when it has something to wait for**. A `body`
8
+ * schema always does; `query` and `params` against a synchronous validator — which
9
+ * zod, Valibot and ArkType all are — resolve without one.
10
+ */
11
+ export type InputReader = (req: BunRequest) => RouteInput | Promise<RouteInput>;
12
+ /**
13
+ * Folds the declared schemas into a single closure, the way `compose` folds
14
+ * middleware: which parsers and validators run is decided here, at boot, so per
15
+ * request there is no metadata to read and no branch left to take.
16
+ */
17
+ export declare const buildInputReader: (options: RouteSchemas | undefined) => InputReader;
@@ -0,0 +1,20 @@
1
+ import type { BunRequest } from 'bun';
2
+ import type { RouteContext } from './context.js';
3
+ export type Next = () => Promise<Response>;
4
+ /**
5
+ * The single extension point. A guard is middleware that throws, an interceptor
6
+ * wraps `next()`, a filter is the error mapper. `ctx` names the route and carries
7
+ * what its decorators declared, resolved at boot — so a guard costs a Map lookup.
8
+ */
9
+ export interface Middleware {
10
+ handle(req: BunRequest, ctx: RouteContext, next: Next): Promise<Response>;
11
+ }
12
+ export type RouteHandler = (req: BunRequest) => Promise<Response>;
13
+ /**
14
+ * What goes into the `Bun.serve` route table. Wider than `RouteHandler` because
15
+ * Bun accepts a plain `Response`, which is what lets a route with nothing to
16
+ * await skip promises altogether — see `buildRoutes`.
17
+ */
18
+ export type ServedHandler = (req: BunRequest) => Response | Promise<Response>;
19
+ /** Folded into one closure per route at boot — no per-request array iteration. */
20
+ export declare const compose: (middleware: readonly Middleware[], ctx: RouteContext, handler: RouteHandler) => RouteHandler;
@@ -0,0 +1,55 @@
1
+ import { Logger, RequestContext } from '@dunx/core';
2
+ import type { BunRequest } from 'bun';
3
+ import type { RouteContext } from './context.js';
4
+ import type { Middleware, Next } from './middleware.js';
5
+ export declare const REQUEST_ID_HEADER = "x-request-id";
6
+ export interface RequestLoggingOptions {
7
+ /** Bodies past this many characters are logged as a size. Default 2048. `0` omits them. */
8
+ readonly maxBodyLength?: number;
9
+ /**
10
+ * Log the request body. Default **`false`**.
11
+ *
12
+ * Reading it means `req.clone().text()` — a second copy of every payload,
13
+ * buffered and parsed, on the hot path. Measured on the `validate` scenario in
14
+ * `tools/bench`, turning both body options on costs roughly two thirds of the
15
+ * throughput. It is also the field most likely to contain a password.
16
+ *
17
+ * Turn it on in development, where seeing the payload is the point.
18
+ */
19
+ readonly requestBody?: boolean;
20
+ /** Log the response body. Default **`false`** — same clone-and-buffer cost. */
21
+ readonly responseBody?: boolean;
22
+ /** Paths to skip entirely — a health check polled every second, say. */
23
+ readonly ignore?: readonly string[];
24
+ }
25
+ /**
26
+ * One structured entry per request, carrying the request and its response.
27
+ *
28
+ * Installed by `HttpFactory.create` unless `requestLogging: false`. It injects
29
+ * `Logger` and `RequestContext` — both `@dunx/core` contracts, both bound by
30
+ * default — so it works with no logging module imported, and picks up
31
+ * `@arkv/logger` automatically once `@dunx/infra/logger` is.
32
+ *
33
+ * **One entry, not two.** Nest needs a middleware for the inbound half and an
34
+ * interceptor for the outbound one, because they are different classes and the
35
+ * interceptor cannot see what the middleware saw. Here they are the same
36
+ * closure, so there is no pair to correlate by `requestId` to find out how a
37
+ * call ended. A 4xx is the same line at `warn`, a 5xx at `error`.
38
+ *
39
+ * Everything the handler logs in between carries `requestId`, `method`, `event`
40
+ * and `context` without being passed anything, because the whole call runs
41
+ * inside `runWithContext`.
42
+ *
43
+ * **Nothing here is `async`.** Reading the request or the response body are the
44
+ * only steps that can ever wait, both are off by default, and both are adopted
45
+ * with `.then` rather than awaited — the same rule `input.ts` follows, for the
46
+ * same measured reason. An `async` scope callback alone cost 0.44 µs/request
47
+ * against a synchronous one on raw `Bun.serve`.
48
+ */
49
+ export declare class RequestLoggingMiddleware implements Middleware {
50
+ #private;
51
+ private readonly logger;
52
+ private readonly context;
53
+ constructor(logger: Logger, context: RequestContext, options?: RequestLoggingOptions);
54
+ handle(req: BunRequest, ctx: RouteContext, next: Next): Promise<Response>;
55
+ }
@@ -0,0 +1,47 @@
1
+ import { type Ctor } from '@dunx/core';
2
+ import type { DiscoveredRoute } from '../route/discover.js';
3
+ import type { HttpMethod } from '../route/marker.js';
4
+ import type { UpgradeHandler } from '../ws/adapter.js';
5
+ import { type CorsOptions } from './cors.js';
6
+ import { type ErrorMapper } from './errors.js';
7
+ import { type Middleware, type RouteHandler, type ServedHandler } from './middleware.js';
8
+ /** How a `@UseGuards` class becomes an instance. `listen()` passes `app.get`. */
9
+ export type GuardResolver = (guard: Ctor<Middleware>) => Middleware;
10
+ /** `OPTIONS` is never a `@Get`-style route — only CORS mounts one. */
11
+ export type RouteMethod = HttpMethod | 'OPTIONS';
12
+ export type BunRoutes = Record<string, Partial<Record<RouteMethod, ServedHandler>>>;
13
+ /**
14
+ * What `listen()` hands `Bun.serve`: the HTTP table plus one `GET` per gateway,
15
+ * whose handler may answer `undefined` because the socket was upgraded.
16
+ */
17
+ export type ServeRoutes = Record<string, Partial<Record<RouteMethod, ServedHandler | UpgradeHandler>>>;
18
+ /**
19
+ * Bun silently lets one route win on a collision, so a duplicate method+path is a
20
+ * boot error naming both handlers. Run twice: once at `create()` on the discovered
21
+ * paths, and again from `buildRoutes` at `listen()` on the final, prefixed ones.
22
+ */
23
+ export declare const assertNoCollisions: (discovered: readonly DiscoveredRoute[]) => void;
24
+ /**
25
+ * A gateway's upgrade is a native route like any other, so a path claimed by both a
26
+ * controller and a gateway would lose one of them when the two tables merge.
27
+ */
28
+ export declare const assertNoGatewayCollisions: (discovered: readonly DiscoveredRoute[], gatewayPaths: readonly string[]) => void;
29
+ /**
30
+ * The two tables in one. A gateway's `GET` is what Bun's router matches on an
31
+ * upgrade — the reason no `fetch` handler is needed for a socket to connect.
32
+ */
33
+ export declare const withUpgradeRoutes: (routes: BunRoutes, gateways: ReadonlyMap<string, UpgradeHandler>) => ServeRoutes;
34
+ /**
35
+ * Bun answers an unmatched path itself, so nothing in the middleware chain ever
36
+ * sees it — which makes a 404 invisible to request logging, metrics and tracing.
37
+ *
38
+ * This is the only `fetch` handler dunx installs, and it is not a router: Bun
39
+ * still does all the matching, and this runs only once Bun has decided nothing
40
+ * matched. It puts the global middleware in front of a 404 in the framework's
41
+ * own error shape.
42
+ *
43
+ * Composed per request rather than at boot, because the context names the path
44
+ * that missed. That allocation is on the 404 path only.
45
+ */
46
+ export declare const buildFallback: (middleware?: readonly Middleware[], onError?: ErrorMapper, cors?: CorsOptions) => RouteHandler;
47
+ export declare const buildRoutes: (discovered: readonly DiscoveredRoute[], middleware?: readonly Middleware[], onError?: ErrorMapper, cors?: CorsOptions, resolve?: GuardResolver) => BunRoutes;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The settings `app.set()` accepts. A key has to be declared here to be settable,
3
+ * so the map is checked at compile time instead of being a string bag — a typo is
4
+ * a type error, not a setting that silently never applies.
5
+ */
6
+ export interface AppSettings {
7
+ /**
8
+ * Resolve the client address from `X-Forwarded-For` rather than the socket. Only
9
+ * turn it on behind a proxy that rewrites the header: a direct client can send
10
+ * whatever it likes.
11
+ */
12
+ 'trust proxy': boolean;
13
+ }
14
+ export declare const defaultSettings: () => AppSettings;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Frozen object plus an indexed-access union, not an `enum`. An enum emits a
3
+ * runtime object that no other syntax can produce, which is why the repo bans it —
4
+ * see CLAUDE.md. This gives the same `HttpStatusCode.NOT_FOUND` ergonomics, a
5
+ * narrower type, and erases cleanly.
6
+ */
7
+ export declare const HttpStatusCode: Readonly<{
8
+ readonly OK: 200;
9
+ readonly CREATED: 201;
10
+ readonly ACCEPTED: 202;
11
+ readonly NO_CONTENT: 204;
12
+ readonly MOVED_PERMANENTLY: 301;
13
+ readonly FOUND: 302;
14
+ readonly NOT_MODIFIED: 304;
15
+ readonly TEMPORARY_REDIRECT: 307;
16
+ readonly PERMANENT_REDIRECT: 308;
17
+ readonly BAD_REQUEST: 400;
18
+ readonly UNAUTHORIZED: 401;
19
+ readonly PAYMENT_REQUIRED: 402;
20
+ readonly FORBIDDEN: 403;
21
+ readonly NOT_FOUND: 404;
22
+ readonly METHOD_NOT_ALLOWED: 405;
23
+ readonly NOT_ACCEPTABLE: 406;
24
+ readonly REQUEST_TIMEOUT: 408;
25
+ readonly CONFLICT: 409;
26
+ readonly GONE: 410;
27
+ readonly PRECONDITION_FAILED: 412;
28
+ readonly PAYLOAD_TOO_LARGE: 413;
29
+ readonly URI_TOO_LONG: 414;
30
+ readonly UNSUPPORTED_MEDIA_TYPE: 415;
31
+ readonly IM_A_TEAPOT: 418;
32
+ readonly UNPROCESSABLE_ENTITY: 422;
33
+ readonly TOO_MANY_REQUESTS: 429;
34
+ readonly INTERNAL_SERVER_ERROR: 500;
35
+ readonly NOT_IMPLEMENTED: 501;
36
+ readonly BAD_GATEWAY: 502;
37
+ readonly SERVICE_UNAVAILABLE: 503;
38
+ readonly GATEWAY_TIMEOUT: 504;
39
+ }>;
40
+ /** The status numbers: `200 | 201 | ...`. */
41
+ export type HttpStatusCode = (typeof HttpStatusCode)[keyof typeof HttpStatusCode];
42
+ /** The names: `'OK' | 'CREATED' | ...`. */
43
+ export type HttpStatusName = keyof typeof HttpStatusCode;
@@ -0,0 +1,21 @@
1
+ import type { BunRequest, Server, WebSocketHandler } from 'bun';
2
+ import type { DiscoveredGateway } from './discover.js';
3
+ import type { SocketData, SocketOptions } from './socket.js';
4
+ /**
5
+ * A gateway's entry in the server's route table. Returning `undefined` is how Bun
6
+ * is told the socket was upgraded; a `Response` is `426` for a request that was not
7
+ * an upgrade, or whatever `@OnUpgrade` refused with.
8
+ */
9
+ export type UpgradeHandler = (req: BunRequest, server: Server<SocketData>) => Response | undefined | Promise<Response | undefined>;
10
+ /**
11
+ * Everything the one `Bun.serve` call needs from the websocket side, built once at
12
+ * boot: the handler object, and one native route per gateway path. Nothing here
13
+ * calls `Bun.serve` itself.
14
+ */
15
+ export interface WebSocketRuntime {
16
+ readonly websocket: WebSocketHandler<SocketData>;
17
+ /** Merged into the HTTP route table by `listen()`, keyed by gateway path. */
18
+ readonly routes: ReadonlyMap<string, UpgradeHandler>;
19
+ readonly paths: readonly string[];
20
+ }
21
+ export declare const buildWebSocket: (discovered: readonly DiscoveredGateway[], options?: SocketOptions) => WebSocketRuntime;
@@ -0,0 +1,17 @@
1
+ type GatewayTarget = abstract new (...args: never[]) => object;
2
+ type HandlerMethod = (...args: never[]) => unknown;
3
+ export declare const Gateway: (path?: string) => <T extends GatewayTarget>(target: T) => T;
4
+ /** Runs before the socket exists. Return a `Response` to refuse the upgrade. */
5
+ export declare const OnUpgrade: () => <T extends HandlerMethod>(value: T) => T;
6
+ export declare const OnOpen: () => <T extends HandlerMethod>(value: T) => T;
7
+ export declare const OnClose: () => <T extends HandlerMethod>(value: T) => T;
8
+ export declare const OnDrain: () => <T extends HandlerMethod>(value: T) => T;
9
+ export declare const OnPing: () => <T extends HandlerMethod>(value: T) => T;
10
+ export declare const OnPong: () => <T extends HandlerMethod>(value: T) => T;
11
+ /**
12
+ * With an event name, the handler is routed the `data` of any
13
+ * `{"event":"<name>","data":...}` frame. With none, it is the raw catch-all and
14
+ * receives every frame no named handler claimed.
15
+ */
16
+ export declare const OnMessage: (event?: string) => <T extends HandlerMethod>(value: T) => T;
17
+ export {};
@@ -0,0 +1,40 @@
1
+ import { type Ctor, type InjectionToken, type ResolvedModule } from '@dunx/core';
2
+ import { type HandlerKind } from './marker.js';
3
+ /**
4
+ * A discovered handler, already bound to its instance. Every kind has a different
5
+ * signature, so the runtime holds them loosely and the decorators are what keep
6
+ * the declared shapes honest.
7
+ */
8
+ export type Invoke = (...args: readonly unknown[]) => unknown;
9
+ export interface DiscoveredHandler {
10
+ readonly kind: HandlerKind;
11
+ readonly event: string | undefined;
12
+ readonly method: string;
13
+ readonly invoke: Invoke;
14
+ }
15
+ export interface DiscoveredGateway {
16
+ readonly name: string;
17
+ readonly path: string;
18
+ readonly handlers: readonly DiscoveredHandler[];
19
+ }
20
+ /** `chat` and `/chat/` both become `/chat`; an empty path becomes `/`. */
21
+ export declare const normalizePath: (path: string) => string;
22
+ /**
23
+ * Walks the prototype chain of a constructed gateway and collects every marked
24
+ * method. Most-derived wins on a repeated name; an undecorated override does not
25
+ * shadow its decorated base, and dispatch still lands on the override because the
26
+ * handler is bound off the instance.
27
+ */
28
+ export declare const discoverGateway: (instance: object) => DiscoveredGateway;
29
+ /**
30
+ * The name of the first handler a class declares, without constructing it. A
31
+ * provider that declares one but is not a gateway would silently never receive a
32
+ * frame, so that becomes a boot error naming the method.
33
+ */
34
+ export declare const findHandlerMethod: (ctor: Ctor<unknown>) => string | undefined;
35
+ /**
36
+ * Gateways are declared in `@Module({ providers })` like any other injectable and
37
+ * found here by their marker — the same discovery-by-inspection controllers get,
38
+ * with no second registration key to keep in step.
39
+ */
40
+ export declare const discoverGateways: (modules: readonly ResolvedModule[], resolve: (token: InjectionToken<unknown>) => unknown) => readonly DiscoveredGateway[];
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The whole wire protocol: one JSON object, an event name, and a payload. It is
3
+ * only ever read for a gateway that declares at least one `@OnMessage(event)`
4
+ * handler — a gateway with only a raw `@OnMessage()` never parses anything.
5
+ */
6
+ export interface Envelope {
7
+ readonly event: string;
8
+ readonly data?: unknown;
9
+ }
10
+ export declare const encode: (event: string, data: unknown) => string;
11
+ /**
12
+ * `undefined` for anything that is not an envelope — binary frames, invalid JSON,
13
+ * a non-object, or a missing `event`. Those fall through to the raw handler
14
+ * instead of being rejected here.
15
+ */
16
+ export declare const decode: (message: string | Buffer) => Envelope | undefined;
@@ -0,0 +1,27 @@
1
+ export declare const HandlerKind: Readonly<{
2
+ readonly UPGRADE: 'upgrade';
3
+ readonly OPEN: 'open';
4
+ readonly MESSAGE: 'message';
5
+ readonly CLOSE: 'close';
6
+ readonly DRAIN: 'drain';
7
+ readonly PING: 'ping';
8
+ readonly PONG: 'pong';
9
+ }>;
10
+ export type HandlerKind = (typeof HandlerKind)[keyof typeof HandlerKind];
11
+ export interface HandlerMeta {
12
+ readonly kind: HandlerKind;
13
+ /**
14
+ * Only meaningful for a message handler: the envelope event it claims.
15
+ * `undefined` is the raw catch-all that sees every unrouted frame.
16
+ */
17
+ readonly event: string | undefined;
18
+ }
19
+ export declare const markHandler: (target: object, meta: HandlerMeta) => void;
20
+ export declare const handlerMetaOf: (value: unknown) => HandlerMeta | undefined;
21
+ export declare const markGateway: (target: object, path: string) => void;
22
+ export declare const gatewayPathOf: (target: object) => string;
23
+ /**
24
+ * `@Gateway` is what separates a gateway from every other provider in the same
25
+ * module, so unlike `@Controller` it is required rather than decorative.
26
+ */
27
+ export declare const isGateway: (target: object) => boolean;
@@ -0,0 +1,52 @@
1
+ import type { Server } from 'bun';
2
+ import { type PubSubRelay, type RelayOptions } from './relay.js';
3
+ import type { SocketData } from './socket.js';
4
+ /**
5
+ * Server-wide publish, delegating to Bun's own pub/sub. Topics live in the
6
+ * runtime, not in a JavaScript registry: `socket.subscribe(topic)` is what joins
7
+ * one, and Bun does the fan-out.
8
+ *
9
+ * Injectable — `HttpFactory` binds it, so a service can publish without holding a
10
+ * socket and without registering anything.
11
+ *
12
+ * With a {@link PubSubRelay} attached the same publish also reaches the other
13
+ * nodes. Without one — the default — nothing here touches a broker and the cost is
14
+ * exactly Bun's.
15
+ */
16
+ export declare class PubSub {
17
+ #private;
18
+ /** Called with the live server by `listen()`; also usable directly. */
19
+ attach(server: Server<SocketData>): void;
20
+ get attached(): boolean;
21
+ /** This process's id on the relay channel. Stable for the process's lifetime. */
22
+ get origin(): string;
23
+ get relaying(): boolean;
24
+ /**
25
+ * Opt into multi-node fan-out: every `publish` from here on also goes to
26
+ * `relay`, and everything other nodes put on the channel is fanned out locally.
27
+ *
28
+ * `HttpFactory.create(root, { relay })` is the shorthand — `listen()` calls this.
29
+ * Call it directly when the relay has to come out of the container, which is the
30
+ * case for an app reusing its own `@dunx/infra/redis` connection:
31
+ * `app.get(PubSub).relayThrough(app.get(RedisConnection))` before `listen()`.
32
+ *
33
+ * A broker that cannot be reached is reported through `onError` and left alone —
34
+ * local fan-out is unaffected, and the app boots either way.
35
+ */
36
+ relayThrough(relay: PubSubRelay, options?: RelayOptions): Promise<void>;
37
+ /** Bytes sent locally, `0` if the message was dropped, `-1` under backpressure. */
38
+ publish(topic: string, data: string | Bun.BufferSource, compress?: boolean): number;
39
+ /** The same envelope `@OnMessage(event)` reads, published to a topic. */
40
+ publishEvent(topic: string, event: string, data?: unknown): number;
41
+ /** Subscribers on **this** node. Bun counts its own sockets and nothing else. */
42
+ subscriberCount(topic: string): number;
43
+ /**
44
+ * Releases a relay this `PubSub` was given, if the relay owns connections.
45
+ *
46
+ * The server reference goes too, which is what makes a relay the *app* owns safe
47
+ * to leave subscribed: `PubSubRelay` has no unsubscribe, so a frame may still
48
+ * arrive on a shared connection after this node stopped, and with no server
49
+ * there is nothing for it to fan out to.
50
+ */
51
+ close(): Promise<void>;
52
+ }
@@ -0,0 +1,52 @@
1
+ import type { PubSubRelay } from './relay.js';
2
+ /** The same fallback chain `Bun.RedisClient` uses when given no URL. */
3
+ export declare const defaultRelayUrl: () => string;
4
+ export interface RedisRelayOptions {
5
+ /** @default `$VALKEY_URL`, `$REDIS_URL`, then `redis://localhost:6379` */
6
+ readonly url?: string;
7
+ /**
8
+ * Bun's reconnection budget.
9
+ *
10
+ * `0` by default, and that default is not a preference: a `Bun.RedisClient` that
11
+ * never connects keeps an internal retry timer alive past `close()`, and the
12
+ * process then never exits. A relay is exactly the connection most likely to be
13
+ * absent — a single-node deployment with `REDIS_URL` left over from staging —
14
+ * so the default has to be the one that lets the app boot, degrade, and still
15
+ * exit. Raise it when Redis is a hard requirement and you want Bun to reconnect
16
+ * for you.
17
+ *
18
+ * @default 0
19
+ */
20
+ readonly maxRetries?: number;
21
+ /** @default 10000 */
22
+ readonly connectionTimeout?: number;
23
+ readonly tls?: boolean | Bun.TLSOptions;
24
+ }
25
+ /**
26
+ * A {@link PubSubRelay} on `Bun.RedisClient` — a Bun global, so this costs
27
+ * `@dunx/http` no dependency at all.
28
+ *
29
+ * **Two connections, not one.** A client in subscriber mode rejects every data
30
+ * command, and throws synchronously doing it, so the subscription cannot share the
31
+ * socket that publishes. This is the same split the socket.io Redis adapter makes
32
+ * with its `pubClient` / `subClient`.
33
+ *
34
+ * Both are opened lazily, on the first call that needs them, and a failed one is
35
+ * discarded so the next call builds a fresh connection rather than reusing a dead
36
+ * one.
37
+ */
38
+ export declare class RedisRelay implements PubSubRelay {
39
+ #private;
40
+ constructor(options?: RedisRelayOptions);
41
+ /** The URL with any password removed, for logs and error messages. */
42
+ get url(): string;
43
+ publish(channel: string, message: string): Promise<number>;
44
+ subscribe(channel: string, listener: (message: string) => void): Promise<void>;
45
+ /**
46
+ * `UNSUBSCRIBE` before `close()`, and that order is load-bearing: measured on
47
+ * Bun 1.3.14, a `Bun.RedisClient` left in subscriber mode keeps the process
48
+ * alive after `close()`, so an app that shut down cleanly would never exit.
49
+ * Leaving subscriber mode first fixes it. Recorded in docs/bun-apis.md.
50
+ */
51
+ close(): Promise<void>;
52
+ }