@dunx/http 3.3.1 → 3.4.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.
@@ -89,6 +89,20 @@ var discoverRoutes2 = (instance) => {
89
89
  }));
90
90
  };
91
91
 
92
+ // src/route/prefix.ts
93
+ class RoutePrefix2 {
94
+ #value = "";
95
+ get value() {
96
+ return this.#value;
97
+ }
98
+ attach(prefix) {
99
+ this.#value = prefix;
100
+ }
101
+ apply(path) {
102
+ return this.#value === "" ? path : joinPath2(this.#value, path);
103
+ }
104
+ }
105
+
92
106
  // src/ws/marker.ts
93
107
  var HANDLER = Symbol.for("dunx.ws.handler");
94
108
  var GATEWAY = Symbol.for("dunx.ws.gateway");
@@ -171,4 +185,4 @@ var discoverGateways = (modules, resolve) => {
171
185
  return discovered;
172
186
  };
173
187
 
174
- export { defaultStatusFor2, markRoute, markController, metaKey2, meta2, ROLES2, PUBLIC2, HIDDEN2, UNMATCHED2, Roles2, Public2, ApiHidden2, UseGuards2, metaOf2, mergeMeta2, joinPath2, discoverRoutes2, HandlerKind, markHandler, markGateway, isGateway2, discoverGateway, discoverGateways, buildContext2 };
188
+ export { defaultStatusFor2, markRoute, markController, metaKey2, meta2, ROLES2, PUBLIC2, HIDDEN2, UNMATCHED2, Roles2, Public2, ApiHidden2, UseGuards2, metaOf2, mergeMeta2, joinPath2, discoverRoutes2, RoutePrefix2, HandlerKind, markHandler, markGateway, isGateway2, discoverGateway, discoverGateways, buildContext2 };
@@ -3,8 +3,13 @@
3
3
  * finishes every `onInit` before `listen()` binds, so a connection refused *is* "not
4
4
  * started yet" and a third endpoint would restate it.
5
5
  *
6
- * `@Public()` because a probe has no credentials. Both routes are documented, under
7
- * the `Health` tag; `HealthModule.forRoot({ documented: false })` mounts
6
+ * `@Public()` because a probe has no credentials, and `@SkipThrottle()` for the same
7
+ * reason: a probe is not a caller with a budget. `app.use(ThrottleGuard)` covers
8
+ * every route, so without the exemption an orchestrator polling a pod that is
9
+ * already shedding load reads 429, calls the process unhealthy and restarts it.
10
+ *
11
+ * Both routes are documented, under the `Health` tag;
12
+ * `HealthModule.forRoot({ documented: false })` mounts
8
13
  * {@link HiddenHealthController} instead.
9
14
  */
10
15
  export declare class HealthController {
package/dist/index.js CHANGED
@@ -18,14 +18,14 @@ import {
18
18
  UseGuards2,
19
19
  metaOf2,
20
20
  mergeMeta2,
21
- joinPath2,
22
21
  discoverRoutes2,
22
+ RoutePrefix2,
23
23
  HandlerKind,
24
24
  markHandler,
25
25
  markGateway,
26
26
  discoverGateways,
27
27
  buildContext2
28
- } from "./chunk-8939brh2.js";
28
+ } from "./chunk-p9hdmkm6.js";
29
29
  import {
30
30
  TRACEPARENT_HEADER2,
31
31
  TRACESTATE_HEADER2,
@@ -1468,6 +1468,15 @@ var buildRoutes = (discovered, middleware = [], onError = defaultErrorMapper, co
1468
1468
  }
1469
1469
  return routes;
1470
1470
  };
1471
+ var withTrailingSlashAliases = (routes) => {
1472
+ const aliased = { ...routes };
1473
+ for (const [path, byMethod] of Object.entries(routes)) {
1474
+ if (path.endsWith("/") || path.includes("*"))
1475
+ continue;
1476
+ aliased[`${path}/`] ??= byMethod;
1477
+ }
1478
+ return aliased;
1479
+ };
1471
1480
 
1472
1481
  // src/server/binding.ts
1473
1482
  class ServerBinding {
@@ -1557,6 +1566,7 @@ class HttpApplication extends ShutdownAware {
1557
1566
  #relayChannel;
1558
1567
  #relayResubscribe;
1559
1568
  #notFound;
1569
+ #strict;
1560
1570
  #bootLogging;
1561
1571
  #binding;
1562
1572
  #split;
@@ -1583,6 +1593,7 @@ class HttpApplication extends ShutdownAware {
1583
1593
  this.#relayChannel = options.relayChannel;
1584
1594
  this.#relayResubscribe = options.relayResubscribe;
1585
1595
  this.#notFound = options.notFound ?? "public";
1596
+ this.#strict = options.strict ?? true;
1586
1597
  this.#bootLogging = options.bootLogging ?? true;
1587
1598
  this.#binding = new ServerBinding({
1588
1599
  http2: options.http2,
@@ -1645,8 +1656,11 @@ class HttpApplication extends ShutdownAware {
1645
1656
  this.#assertNotStarted("listen()");
1646
1657
  this.#started = true;
1647
1658
  const middleware = this.#middleware.map((entry) => this.#app.get(entry, this.#root));
1648
- const prefixed = this.#prefixed();
1649
- const routes = buildRoutes(prefixed, middleware, this.#onError, this.#cors, (guard, from) => from === undefined ? this.#app.get(guard) : this.#app.get(guard, from));
1659
+ const prefix = this.#app.get(RoutePrefix2);
1660
+ prefix.attach(this.#globalPrefix);
1661
+ const prefixed = this.#prefixed(prefix);
1662
+ const built = buildRoutes(prefixed, middleware, this.#onError, this.#cors, (guard, from) => from === undefined ? this.#app.get(guard) : this.#app.get(guard, from));
1663
+ const routes = this.#strict ? built : withTrailingSlashAliases(built);
1650
1664
  const ws = this.#websocket;
1651
1665
  if (ws && !this.#split)
1652
1666
  assertNoGatewayCollisions(prefixed, ws.paths);
@@ -1726,12 +1740,12 @@ class HttpApplication extends ShutdownAware {
1726
1740
  })();
1727
1741
  return this.#shuttingDown;
1728
1742
  }
1729
- #prefixed() {
1730
- if (this.#globalPrefix === "")
1743
+ #prefixed(prefix) {
1744
+ if (prefix.value === "")
1731
1745
  return this.#discovered;
1732
1746
  return this.#discovered.map((route) => ({
1733
1747
  ...route,
1734
- path: joinPath2(this.#globalPrefix, route.path)
1748
+ path: prefix.apply(route.path)
1735
1749
  }));
1736
1750
  }
1737
1751
  #assertNotStarted(hook) {
@@ -1749,6 +1763,7 @@ class HttpOptionsProvider {
1749
1763
  notFound = "public";
1750
1764
  bootLogging = true;
1751
1765
  trustProxy = false;
1766
+ strict = true;
1752
1767
  shutdownHooks = false;
1753
1768
  relayChannel = "dunx:ws";
1754
1769
  get prefix() {
@@ -1801,6 +1816,7 @@ function resolveHttpOptions(settings, given) {
1801
1816
  notFound: settings.notFound,
1802
1817
  bootLogging: settings.bootLogging,
1803
1818
  trustProxy: settings.trustProxy,
1819
+ strict: settings.strict,
1804
1820
  shutdownHooks: settings.shutdownHooks,
1805
1821
  relayChannel: settings.relayChannel,
1806
1822
  prefix: settings.prefix,
@@ -1850,7 +1866,7 @@ class HttpFactory {
1850
1866
  useFactory: (logger, context, settings) => new SocketLoggingMiddleware(logger, context, pick(options.socketLogging, settings.socketLogging)),
1851
1867
  inject: [Logger4, RequestContext3, HttpOptionsProvider]
1852
1868
  });
1853
- const services = [PubSub, ClientAddress, RequestMetrics];
1869
+ const services = [PubSub, ClientAddress, RequestMetrics, RoutePrefix2];
1854
1870
  const providers = [...services, logging, metricsMiddleware, socketLogging];
1855
1871
  const scope = {
1856
1872
  module: HttpModule,
@@ -2307,7 +2323,7 @@ class ThrottleOptions {
2307
2323
  subject;
2308
2324
  store;
2309
2325
  constructor(init) {
2310
- if (init.prefix.trim() === "") {
2326
+ if (typeof init.prefix !== "string" || init.prefix.trim() === "") {
2311
2327
  throw new AppError9("ThrottleModule needs a prefix naming this application, and it has no " + "default: two apps sharing one Redis with one throttle namespace each " + "spend the other's budget. Pass something like { prefix: 'orders-api' }.");
2312
2328
  }
2313
2329
  if (!Number.isInteger(init.limit) || init.limit < 1) {
@@ -2957,10 +2973,12 @@ var _dec = [
2957
2973
  ];
2958
2974
  var _dec2 = [
2959
2975
  Public2(),
2976
+ SkipThrottle(),
2960
2977
  Get("/live", probeResponses)
2961
2978
  ];
2962
2979
  var _dec3 = [
2963
2980
  Public2(),
2981
+ SkipThrottle(),
2964
2982
  Get("/ready", probeResponses)
2965
2983
  ];
2966
2984
  var _health = new WeakMap;
@@ -12,6 +12,7 @@
12
12
  * No stability promise attaches to this subpath.
13
13
  */
14
14
  export { discoverRoutes, joinPath, type DiscoveredRoute, } from './route/discover.js';
15
+ export { RoutePrefix } from './route/prefix.js';
15
16
  export { defaultStatusFor } from './route/marker.js';
16
17
  export { gatewaysOf, routesOf, type GatewayHandler, type GatewayNode, type RouteInputs, type RouteNode, } from './inspect.js';
17
18
  export { buildContext } from './server/context.js';
package/dist/internal.js CHANGED
@@ -7,10 +7,11 @@ import {
7
7
  HIDDEN2,
8
8
  joinPath2,
9
9
  discoverRoutes2,
10
+ RoutePrefix2,
10
11
  isGateway2,
11
12
  discoverGateway,
12
13
  buildContext2
13
- } from "./chunk-8939brh2.js";
14
+ } from "./chunk-p9hdmkm6.js";
14
15
  // src/inspect.ts
15
16
  import {
16
17
  classOf,
@@ -71,6 +72,7 @@ var gatewaysOf = (root) => collectModules(root).flatMap((module) => (module.opti
71
72
  // src/server/html.ts
72
73
  var embedJson = (value) => JSON.stringify(value).replaceAll("<", "\\u003c");
73
74
  export {
75
+ RoutePrefix2 as RoutePrefix,
74
76
  buildContext2 as buildContext,
75
77
  defaultStatusFor2 as defaultStatusFor,
76
78
  discoverRoutes2 as discoverRoutes,
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The global prefix, as `listen()` resolved it.
3
+ *
4
+ * Bound by `HttpFactory`'s global wrapper next to `ClientAddress`, and attached
5
+ * the same way: a second instance reads an empty prefix, which is a plausible
6
+ * answer and a wrong one. Routes only, never gateways.
7
+ * See docs/architecture/http.md, "Who knows the global prefix".
8
+ */
9
+ export declare class RoutePrefix {
10
+ #private;
11
+ /** Empty when the app set none. */
12
+ get value(): string;
13
+ attach(prefix: string): void;
14
+ /** Where a discovered route is served. The one place that answers it. */
15
+ apply(path: string): string;
16
+ }
@@ -1,6 +1,6 @@
1
1
  import type { BunRequest } from 'bun';
2
2
  import { ShutdownAware, type App, type Ctor, type InjectionToken, type ModuleRef } from '@dunx/core';
3
- import { type DiscoveredRoute } from '../route/discover.js';
3
+ import type { DiscoveredRoute } from '../route/discover.js';
4
4
  import type { WebSocketRuntime } from '../ws/adapter.js';
5
5
  import type { CorsOptions } from './cors.js';
6
6
  import type { Middleware } from './middleware.js';
@@ -54,6 +54,11 @@ export declare abstract class HttpOptionsProvider {
54
54
  * nothing lets any caller choose its own address.
55
55
  */
56
56
  readonly trustProxy: boolean;
57
+ /**
58
+ * Match a path exactly as declared, so `/users/1/` is not `/users/1`. On by
59
+ * default, which is `Bun.serve`'s own behaviour and hono's.
60
+ */
61
+ readonly strict: boolean;
57
62
  /**
58
63
  * Install `SIGTERM`/`SIGINT` handlers that shut the app down. Off by default,
59
64
  * because installing a signal handler changes how the process terminates and
@@ -33,6 +33,17 @@ export interface HttpOptions extends AppOptions {
33
33
  readonly cors?: CorsOptions;
34
34
  /** `app.set('trust proxy', ...)` as a field. */
35
35
  readonly trustProxy?: boolean;
36
+ /**
37
+ * Match a path exactly as declared, so `/users/1/` is not `/users/1`.
38
+ *
39
+ * **`true` by default**, which is what `Bun.serve({ routes })` matches on its
40
+ * own, and hono's name and default for the same switch. `false` serves both
41
+ * spellings, as Nest, express and elysia do - every route but `/` and a
42
+ * wildcard mount, which already matches its own trailing slash.
43
+ *
44
+ * Not an `app.set()` setting: the route table is built once, at `listen()`.
45
+ */
46
+ readonly strict?: boolean;
36
47
  /**
37
48
  * Calls `enableShutdownHooks` at construction. `true` takes the default signals;
38
49
  * an object names them and tunes the force-exit.
@@ -60,3 +60,19 @@ export declare const withUpgradeRoutes: (routes: BunRoutes, gateways: ReadonlyMa
60
60
  */
61
61
  export declare const buildFallback: (middleware?: readonly Middleware[], onError?: ErrorMapper, cors?: CorsOptions, notFound?: 'guarded' | 'public') => RouteHandler;
62
62
  export declare const buildRoutes: (discovered: readonly DiscoveredRoute[], middleware?: readonly Middleware[], onError?: ErrorMapper, cors?: CorsOptions, resolve?: GuardResolver) => BunRoutes;
63
+ /**
64
+ * A second key per route ending in `/`, holding the handlers the first one has.
65
+ *
66
+ * `Bun.serve({ routes })` matches the literal path, so `/users/1/` misses when
67
+ * `/users/:id` is what was registered. Opt in with `strict: false`. The
68
+ * measurements, and what the other frameworks do, are in
69
+ * docs/architecture/http.md.
70
+ *
71
+ * A key rather than a copy: one per-method object under two names, so the
72
+ * pattern `buildContext` froze still labels the metrics series and the log line.
73
+ * It runs after the CORS preflight is mounted, so the alias carries `OPTIONS`.
74
+ *
75
+ * `/` and any `*` path are skipped - `//` is neither, and a wildcard already
76
+ * matches its own trailing slash.
77
+ */
78
+ export declare const withTrailingSlashAliases: (routes: BunRoutes) => BunRoutes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/http",
3
- "version": "3.3.1",
3
+ "version": "3.4.0",
4
4
  "description": "Bun.serve adapter for the dunx framework: controllers, middleware and WebSocket gateways",
5
5
  "keywords": [
6
6
  "bun",
@@ -65,7 +65,7 @@
65
65
  "@opentelemetry/sdk-trace-node": "2.11.0"
66
66
  },
67
67
  "peerDependencies": {
68
- "@dunx/core": "^3.3.1",
68
+ "@dunx/core": "^3.4.0",
69
69
  "@types/bun": ">=1.4.1"
70
70
  },
71
71
  "peerDependenciesMeta": {