@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.
- package/dist/health/registry.d.ts +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +561 -47
- package/dist/index.js.map +19 -11
- package/dist/server/application.d.ts +31 -0
- package/dist/server/errors.d.ts +15 -1
- package/dist/server/factory.d.ts +1 -0
- package/dist/server/request-id.d.ts +27 -0
- package/dist/server/request-logging.d.ts +0 -1
- package/dist/server/routes.d.ts +20 -0
- package/dist/static/module.d.ts +36 -2
- package/dist/throttle/decorators.d.ts +22 -0
- package/dist/throttle/guard.d.ts +31 -0
- package/dist/throttle/module.d.ts +35 -0
- package/dist/throttle/options.d.ts +52 -0
- package/dist/throttle/store.d.ts +71 -0
- package/dist/ws/adapter.d.ts +7 -1
- package/dist/ws/logging.d.ts +69 -0
- package/dist/ws/middleware.d.ts +95 -0
- package/dist/ws/socket.d.ts +18 -1
- package/package.json +2 -2
|
@@ -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
|
}
|
package/dist/server/errors.d.ts
CHANGED
|
@@ -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
|
-
|
|
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';
|
package/dist/server/factory.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/routes.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/static/module.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/ws/adapter.d.ts
CHANGED
|
@@ -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;
|
package/dist/ws/socket.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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.
|
|
61
|
+
"@dunx/core": "^2.2.1",
|
|
62
62
|
"@types/bun": ">=1.3.0"
|
|
63
63
|
},
|
|
64
64
|
"peerDependenciesMeta": {
|