adonisjs-server-stats 1.14.1 → 1.15.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.
package/README.md CHANGED
@@ -181,6 +181,7 @@ All fields are optional. `defineConfig({})` works with zero configuration.
181
181
  | `statsEndpoint` | `string \| false` | `'/admin/api/server-stats'` | HTTP endpoint. `false` to disable. |
182
182
  | `authorize` | `(ctx) => boolean` | -- | Per-request visibility guard |
183
183
  | `unsafeAllowNoAuth` | `boolean` | `false` | Register the dashboard/debug/stats routes even with **no** `authorize` guard. Off by default (routes fail closed). Local dev only — exposes secrets, email bodies, and SQL. |
184
+ | `domain` | `string` | -- | Restrict every route to one host, e.g. `'admin.example.com'`. See [Custom domain](#custom-domain) |
184
185
  | `onStats` | `(stats) => void` | -- | Callback after each collection tick |
185
186
  | `toolbar` | `boolean \| ToolbarConfig` | -- | `true` to enable with defaults, or pass a `ToolbarConfig` object |
186
187
  | `dashboard` | `boolean \| DashboardConfig` | -- | `true` to enable at `/__stats`, or pass a `DashboardConfig` |
@@ -440,6 +441,33 @@ Registered when `dashboard` is enabled. Base path configurable via `dashboard.pa
440
441
  | POST | `/api/filters` | Create saved filter |
441
442
  | DELETE | `/api/filters/:id` | Delete saved filter |
442
443
 
444
+ ### Custom domain
445
+
446
+ By default every route is registered on whatever host your app serves. Set `domain` to bind them to one host instead -- useful when your admin surface already lives on its own subdomain:
447
+
448
+ ```ts
449
+ export default defineConfig({
450
+ domain: 'admin.example.com',
451
+ authorize: (ctx) => ctx.auth?.user?.role === 'admin',
452
+ toolbar: true,
453
+ dashboard: true,
454
+ })
455
+ ```
456
+
457
+ The dashboard is then reachable at `admin.example.com/__stats` and nowhere else -- a request to `example.com/__stats` no longer matches any route. Dynamic subdomains use AdonisJS's `:param` syntax:
458
+
459
+ ```ts
460
+ export default defineConfig({
461
+ domain: ':tenant.example.com', // matches acme.example.com, globex.example.com, ...
462
+ })
463
+ ```
464
+
465
+ Pass a bare host. A protocol, path, or port (`https://admin.example.com`, `admin.example.com:3333`) produces routes that match nothing; `defineConfig` warns when it spots one.
466
+
467
+ > **The toolbar follows the domain.** The `@serverStats()` Edge tag and the React/Vue components request **relative** URLs, so they only work on pages served from the configured domain. Render the toolbar on `example.com` while `domain` points at `admin.example.com` and the bar will sit empty -- its polls 404 against the wrong host. Either serve the toolbar from the same domain, or leave `domain` unset and rely on `authorize` alone.
468
+
469
+ > **Reminder:** routes are never registered in production regardless of this setting (`app.inProduction` short-circuits registration), so `domain` applies to your dev and staging environments.
470
+
443
471
  ### Global middleware note
444
472
 
445
473
  Auto-registered routes bypass route-level middleware but are still subject to global/server middleware.
@@ -804,6 +804,35 @@ export interface ServerStatsConfig {
804
804
  * ```
805
805
  */
806
806
  dashboard?: boolean | DashboardConfig;
807
+ /**
808
+ * Restrict all server-stats routes to a specific domain or subdomain.
809
+ *
810
+ * When set, routes are only matched when the request's `Host` header
811
+ * matches the given domain. Useful when admin routes live on a
812
+ * dedicated subdomain (e.g. `admin.example.com`).
813
+ *
814
+ * Supports dynamic subdomains using `:param` syntax
815
+ * (e.g. `':tenant.example.com'`).
816
+ *
817
+ * Pass a bare host — a protocol, path, or port (`'https://admin.example.com'`,
818
+ * `'admin.example.com:3333'`) yields routes that match nothing.
819
+ *
820
+ * Note that the `@serverStats()` toolbar and the React/Vue components request
821
+ * relative URLs, so they only work on pages served from this domain.
822
+ *
823
+ * @example
824
+ * ```ts
825
+ * // Fixed subdomain
826
+ * domain: 'admin.example.com'
827
+ * ```
828
+ *
829
+ * @example
830
+ * ```ts
831
+ * // Dynamic subdomain
832
+ * domain: ':tenant.example.com'
833
+ * ```
834
+ */
835
+ domain?: string;
807
836
  /**
808
837
  * Advanced options for fine-tuning internal behavior.
809
838
  *
@@ -871,4 +900,6 @@ export interface ResolvedServerStatsConfig {
871
900
  advanced?: AdvancedConfig;
872
901
  /** Whether verbose informational logging is enabled. Always present after `defineConfig()`. */
873
902
  verbose: boolean;
903
+ /** Optional domain restriction for all routes. */
904
+ domain?: string;
874
905
  }
@@ -1,5 +1,5 @@
1
1
  import { logDeprecationWarnings } from './config/deprecation_migration.js';
2
- import { setVerbose } from './utils/logger.js';
2
+ import { log, setVerbose } from './utils/logger.js';
3
3
  // ---------------------------------------------------------------------------
4
4
  // Toolbar alias resolution helpers
5
5
  // ---------------------------------------------------------------------------
@@ -126,10 +126,34 @@ function resolveDevToolbar(config) {
126
126
  function first(primary, fallback, defaultVal) {
127
127
  return primary ?? fallback ?? defaultVal;
128
128
  }
129
+ /**
130
+ * Warn about `domain` values that AdonisJS will never match.
131
+ *
132
+ * `.domain()` compares against the request's host only -- a protocol, path, or
133
+ * port in the value silently yields routes that match nothing, which is very
134
+ * hard to debug from the outside. Warn instead of throwing so a bad value
135
+ * degrades to "dashboard not reachable" rather than "app won't boot".
136
+ */
137
+ function warnAboutDomain(domain) {
138
+ const problems = [];
139
+ if (domain.includes('://'))
140
+ problems.push('remove the protocol (`http://` / `https://`)');
141
+ if (domain.includes('/'))
142
+ problems.push('remove the path — only the host is matched');
143
+ if (/:\d+$/.test(domain))
144
+ problems.push('remove the port — it is not part of the host match');
145
+ if (problems.length === 0)
146
+ return;
147
+ log.warn(`server-stats: \`domain: '${domain}'\` looks wrong — ${problems.join('; ')}. ` +
148
+ "Expected a bare host such as 'admin.example.com' or ':tenant.example.com'. " +
149
+ 'As written, no server-stats route will ever match.');
150
+ }
129
151
  export function defineConfig(config) {
130
152
  const verbose = config.verbose ?? false;
131
153
  setVerbose(verbose);
132
154
  logDeprecationWarnings(config);
155
+ if (config.domain)
156
+ warnAboutDomain(config.domain);
133
157
  return {
134
158
  intervalMs: first(config.pollInterval, config.intervalMs, 3000),
135
159
  transport: resolveTransport(config),
@@ -142,5 +166,6 @@ export function defineConfig(config) {
142
166
  shouldShow: config.authorize ?? config.shouldShow,
143
167
  unsafeAllowNoAuth: config.unsafeAllowNoAuth,
144
168
  verbose,
169
+ domain: config.domain,
145
170
  };
146
171
  }
@@ -13,9 +13,18 @@ export declare function computeDashboardPath(devToolbar?: {
13
13
  dashboard?: boolean;
14
14
  dashboardPath?: string;
15
15
  }, depsAvailable?: boolean): string | undefined;
16
- export declare function collectRegisteredPaths(statsEndpoint: string | false, debugEndpoint?: string, dashboardPath?: string): string[];
16
+ export declare function collectRegisteredPaths(statsEndpoint: string | false, debugEndpoint?: string, dashboardPath?: string, domain?: string): string[];
17
17
  export declare function checkDashboardDeps(appImport: (name: string) => Promise<unknown>): Promise<string[]>;
18
18
  export declare function logMissingDeps(missing: string[]): void;
19
19
  export declare function warnAboutAuthMiddleware(config: ResolvedServerStatsConfig, makePath: (dir: string, file: string) => string): void;
20
+ /**
21
+ * Warn when `domain` is combined with the Edge toolbar.
22
+ *
23
+ * The `@serverStats()` tag emits *relative* URLs (`/admin/api/server-stats`
24
+ * etc.), so a toolbar rendered on any other host polls its own origin and gets
25
+ * a 404 on every tick. Nothing breaks visibly — the bar just stays empty — so
26
+ * say it out loud at boot.
27
+ */
28
+ export declare function warnAboutDomainWithToolbar(config: ResolvedServerStatsConfig): void;
20
29
  export declare function warnAboutSessionMiddleware(makePath: (dir: string, file: string) => string): void;
21
30
  export declare function logDashboardError(category: 'missing-dep' | 'timeout' | 'unknown', err: unknown): void;
@@ -12,7 +12,7 @@ export function computeDashboardPath(devToolbar, depsAvailable) {
12
12
  return undefined;
13
13
  return devToolbar.dashboardPath ?? '/__stats';
14
14
  }
15
- export function collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath) {
15
+ export function collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath, domain) {
16
16
  const paths = [];
17
17
  if (typeof statsEndpoint === 'string')
18
18
  paths.push(statsEndpoint);
@@ -20,7 +20,9 @@ export function collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPa
20
20
  paths.push(debugEndpoint + '/*');
21
21
  if (dashboardPath)
22
22
  paths.push(dashboardPath + '/*');
23
- return paths;
23
+ // Without the host prefix the logged paths are misleading when the routes are
24
+ // domain-restricted — they only resolve on that host.
25
+ return domain ? paths.map((p) => domain + p) : paths;
24
26
  }
25
27
  export async function checkDashboardDeps(appImport) {
26
28
  const missing = [];
@@ -57,6 +59,22 @@ export function warnAboutAuthMiddleware(config, makePath) {
57
59
  return;
58
60
  log.block(bold('found global auth middleware that will run on every poll:'), buildAuthMiddlewareWarning(found, dim, bold));
59
61
  }
62
+ /**
63
+ * Warn when `domain` is combined with the Edge toolbar.
64
+ *
65
+ * The `@serverStats()` tag emits *relative* URLs (`/admin/api/server-stats`
66
+ * etc.), so a toolbar rendered on any other host polls its own origin and gets
67
+ * a 404 on every tick. Nothing breaks visibly — the bar just stays empty — so
68
+ * say it out loud at boot.
69
+ */
70
+ export function warnAboutDomainWithToolbar(config) {
71
+ if (!config.domain || !config.devToolbar?.enabled)
72
+ return;
73
+ log.block(bold(`routes are restricted to ${config.domain}:`), [
74
+ dim('The @serverStats() toolbar uses relative URLs, so it only works on pages'),
75
+ dim(`served from ${config.domain}. On any other host the bar will stay empty.`),
76
+ ]);
77
+ }
60
78
  export function warnAboutSessionMiddleware(makePath) {
61
79
  const found = detectGlobalSessionMiddleware(makePath);
62
80
  if (found.length === 0)
@@ -2,7 +2,7 @@ import { StatsEngine } from '../engine/stats_engine.js';
2
2
  import { setShouldShow, setExcludedPrefixes } from '../middleware/request_tracking_middleware.js';
3
3
  import { registerAllRoutes } from '../routes/register_routes.js';
4
4
  import { log, dim, setVerbose } from '../utils/logger.js';
5
- import { deriveEndpointPaths, computeDashboardPath, collectRegisteredPaths, warnAboutAuthMiddleware, warnAboutSessionMiddleware, } from './boot_helpers.js';
5
+ import { deriveEndpointPaths, computeDashboardPath, collectRegisteredPaths, warnAboutAuthMiddleware, warnAboutSessionMiddleware, warnAboutDomainWithToolbar, } from './boot_helpers.js';
6
6
  import { resolveToolbarConfig, buildExcludedPrefixes } from './dashboard_setup.js';
7
7
  import { buildDiagnostics } from './diagnostics.js';
8
8
  import { hookPinoToLogStream, setupLogStreamBroadcast, setupStatsIntervalHelper, checkDashboardDepsHelper, registerEdgePluginHelper, setupNonWebBridgeHelper, setupDevToolbarCore, applyToolbarResult, } from './provider_helpers_extra.js';
@@ -96,13 +96,15 @@ export default class ServerStatsProvider {
96
96
  shouldShow: config.shouldShow,
97
97
  unsafeAllowNoAuth: config.unsafeAllowNoAuth,
98
98
  whenReady: () => this.whenReady(),
99
+ domain: config.domain,
99
100
  });
100
- const paths = collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath);
101
+ const paths = collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath, config.domain);
101
102
  if (paths.length === 0)
102
103
  return;
103
104
  log.list('routes auto-registered (no manual setup needed):', paths);
104
105
  warnAboutAuthMiddleware(config, this.app.makePath.bind(this.app));
105
106
  warnAboutSessionMiddleware(this.app.makePath.bind(this.app));
107
+ warnAboutDomainWithToolbar(config);
106
108
  }
107
109
  async ready() {
108
110
  const config = this.app.config.get('server_stats');
@@ -9,6 +9,7 @@ interface DashboardRoutesOpts {
9
9
  getApiController: () => ApiController | null;
10
10
  middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>;
11
11
  whenReady?: () => Promise<void>;
12
+ domain?: string;
12
13
  }
13
14
  /** Register dashboard routes. */
14
15
  export declare function registerDashboardRoutes(opts: DashboardRoutesOpts): void;
@@ -230,7 +230,7 @@ export function registerDashboardRoutes(opts) {
230
230
  const { router, dashboardPath, getDashboardController, getApiController, middleware } = opts;
231
231
  const whenReady = opts.whenReady;
232
232
  const base = dashboardPath.replace(/\/+$/, '');
233
- router
233
+ const group = router
234
234
  .group(() => {
235
235
  registerDashboardPageRoutes(router, getDashboardController, whenReady);
236
236
  registerQueryRoutes(router, getApiController, whenReady);
@@ -244,6 +244,8 @@ export function registerDashboardRoutes(opts) {
244
244
  registerJobRoutes(router, getDashboardController, whenReady);
245
245
  registerConfigAndFilterRoutes(router, getDashboardController, whenReady);
246
246
  })
247
- .prefix(base)
248
- .use(middleware);
247
+ .prefix(base);
248
+ if (opts.domain)
249
+ group.domain(opts.domain);
250
+ group.use(middleware);
249
251
  }
@@ -13,6 +13,7 @@ interface DebugRoutesOpts {
13
13
  getApp?: () => ApplicationService | null;
14
14
  middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>;
15
15
  whenReady?: () => Promise<void>;
16
+ domain?: string;
16
17
  }
17
18
  /** Register debug panel API routes. */
18
19
  export declare function registerDebugRoutes(opts: DebugRoutesOpts): void;
@@ -149,7 +149,7 @@ export function registerDebugRoutes(opts) {
149
149
  const { router, debugEndpoint, getDebugController, getApiController, middleware } = opts;
150
150
  const whenReady = opts.whenReady;
151
151
  const base = debugEndpoint.replace(/\/+$/, '');
152
- router
152
+ const group = router
153
153
  .group(() => {
154
154
  registerDebugConfigRoutes(router, getDebugController, whenReady);
155
155
  registerDebugQueryAndEventRoutes(router, getApiController, whenReady);
@@ -158,6 +158,8 @@ export function registerDebugRoutes(opts) {
158
158
  registerDebugEmailRoutes(router, getApiController, whenReady);
159
159
  registerDebugTraceRoutes(router, getApiController, whenReady);
160
160
  })
161
- .prefix(base)
162
- .use(middleware);
161
+ .prefix(base);
162
+ if (opts.domain)
163
+ group.domain(opts.domain);
164
+ group.use(middleware);
163
165
  }
@@ -31,6 +31,11 @@ export interface RegisterRoutesOptions {
31
31
  unsafeAllowNoAuth?: boolean;
32
32
  /** Optional promise that resolves when controllers are initialized. */
33
33
  whenReady?: () => Promise<void>;
34
+ /**
35
+ * Restrict every registered route to this host, via `router.group().domain()`.
36
+ * Supports dynamic segments (`':tenant.example.com'`). Unset = no restriction.
37
+ */
38
+ domain?: string;
34
39
  }
35
40
  /**
36
41
  * Register all server-stats routes in a single call.
@@ -31,7 +31,13 @@ export function registerAllRoutes(options) {
31
31
  ...(options.shouldShow ? [createAccessMiddleware(options.shouldShow)] : []),
32
32
  ];
33
33
  if (typeof options.statsEndpoint === 'string') {
34
- registerStatsRoute(options.router, options.statsEndpoint, options.getStatsController, middleware);
34
+ registerStatsRoute({
35
+ router: options.router,
36
+ endpoint: options.statsEndpoint,
37
+ getController: options.getStatsController,
38
+ middleware,
39
+ domain: options.domain,
40
+ });
35
41
  }
36
42
  if (options.debugEndpoint) {
37
43
  registerDebugRoutes({
@@ -43,6 +49,7 @@ export function registerAllRoutes(options) {
43
49
  getApp: options.getApp,
44
50
  middleware,
45
51
  whenReady: options.whenReady,
52
+ domain: options.domain,
46
53
  });
47
54
  }
48
55
  if (options.dashboardPath) {
@@ -53,6 +60,7 @@ export function registerAllRoutes(options) {
53
60
  getApiController: options.getApiController,
54
61
  middleware,
55
62
  whenReady: options.whenReady,
63
+ domain: options.domain,
56
64
  });
57
65
  }
58
66
  }
@@ -10,6 +10,20 @@ export interface AdonisRoute {
10
10
  where(key: string, matcher: RegExp): AdonisRoute;
11
11
  use(middleware: unknown[]): void;
12
12
  }
13
+ /**
14
+ * Return type of `router.group()` supporting the chaining patterns
15
+ * used by server-stats route registration.
16
+ *
17
+ * AdonisJS allows chaining `.prefix()`, `.domain()`, and `.use()` in
18
+ * any order on a route group. This interface covers the combinations
19
+ * we use: `.prefix().use()`, `.prefix().domain().use()`, and
20
+ * `.domain().prefix().use()`.
21
+ */
22
+ export interface AdonisRouteGroup {
23
+ prefix(path: string): AdonisRouteGroup;
24
+ domain(host: string): AdonisRouteGroup;
25
+ use(middleware: unknown[]): AdonisRouteGroup;
26
+ }
13
27
  /**
14
28
  * Minimal interface for the AdonisJS router used in route registration.
15
29
  *
@@ -20,9 +34,5 @@ export interface AdonisRouter {
20
34
  get(pattern: string, handler: (ctx: HttpContext) => unknown): AdonisRoute;
21
35
  post(pattern: string, handler: (ctx: HttpContext) => unknown): AdonisRoute;
22
36
  delete(pattern: string, handler: (ctx: HttpContext) => unknown): AdonisRoute;
23
- group(callback: () => void): {
24
- prefix(path: string): {
25
- use(middleware: unknown[]): void;
26
- };
27
- };
37
+ group(callback: () => void): AdonisRouteGroup;
28
38
  }
@@ -1,5 +1,14 @@
1
1
  import type ServerStatsController from '../controller/server_stats_controller.js';
2
2
  import type { AdonisRouter } from './router_types.js';
3
3
  import type { HttpContext } from '@adonisjs/core/http';
4
+ interface StatsRouteOpts {
5
+ router: AdonisRouter;
6
+ endpoint: string;
7
+ getController: () => ServerStatsController | null;
8
+ middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>;
9
+ /** Optional domain restriction — see `ServerStatsConfig['domain']`. */
10
+ domain?: string;
11
+ }
4
12
  /** Register the stats polling endpoint. */
5
- export declare function registerStatsRoute(router: AdonisRouter, endpoint: string, getController: () => ServerStatsController | null, middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>): void;
13
+ export declare function registerStatsRoute(opts: StatsRouteOpts): void;
14
+ export {};
@@ -1,6 +1,7 @@
1
1
  /** Register the stats polling endpoint. */
2
- export function registerStatsRoute(router, endpoint, getController, middleware) {
3
- router
2
+ export function registerStatsRoute(opts) {
3
+ const { router, endpoint, getController, middleware, domain } = opts;
4
+ const register = () => router
4
5
  .get(endpoint, async (ctx) => {
5
6
  const controller = getController();
6
7
  if (!controller)
@@ -11,4 +12,10 @@ export function registerStatsRoute(router, endpoint, getController, middleware)
11
12
  })
12
13
  .as('server-stats.api')
13
14
  .use(middleware);
15
+ // A domain restriction needs a group to hang `.domain()` on; without one we
16
+ // register the route directly so the route tree stays exactly as before.
17
+ if (domain)
18
+ router.group(register).domain(domain);
19
+ else
20
+ register();
14
21
  }
@@ -804,6 +804,35 @@ export interface ServerStatsConfig {
804
804
  * ```
805
805
  */
806
806
  dashboard?: boolean | DashboardConfig;
807
+ /**
808
+ * Restrict all server-stats routes to a specific domain or subdomain.
809
+ *
810
+ * When set, routes are only matched when the request's `Host` header
811
+ * matches the given domain. Useful when admin routes live on a
812
+ * dedicated subdomain (e.g. `admin.example.com`).
813
+ *
814
+ * Supports dynamic subdomains using `:param` syntax
815
+ * (e.g. `':tenant.example.com'`).
816
+ *
817
+ * Pass a bare host — a protocol, path, or port (`'https://admin.example.com'`,
818
+ * `'admin.example.com:3333'`) yields routes that match nothing.
819
+ *
820
+ * Note that the `@serverStats()` toolbar and the React/Vue components request
821
+ * relative URLs, so they only work on pages served from this domain.
822
+ *
823
+ * @example
824
+ * ```ts
825
+ * // Fixed subdomain
826
+ * domain: 'admin.example.com'
827
+ * ```
828
+ *
829
+ * @example
830
+ * ```ts
831
+ * // Dynamic subdomain
832
+ * domain: ':tenant.example.com'
833
+ * ```
834
+ */
835
+ domain?: string;
807
836
  /**
808
837
  * Advanced options for fine-tuning internal behavior.
809
838
  *
@@ -871,4 +900,6 @@ export interface ResolvedServerStatsConfig {
871
900
  advanced?: AdvancedConfig;
872
901
  /** Whether verbose informational logging is enabled. Always present after `defineConfig()`. */
873
902
  verbose: boolean;
903
+ /** Optional domain restriction for all routes. */
904
+ domain?: string;
874
905
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adonisjs-server-stats",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "description": "Real-time server monitoring for AdonisJS v6 applications",
5
5
  "keywords": [
6
6
  "adonisjs",