Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/define_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,5 +164,6 @@ export function defineConfig(config: ServerStatsConfig): ResolvedServerStatsConf
devToolbar: resolveDevToolbar(config),
shouldShow: config.authorize ?? config.shouldShow,
verbose,
domain: config.domain,
}
}
1 change: 1 addition & 0 deletions src/provider/server_stats_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export default class ServerStatsProvider {
dashboardPath,
shouldShow: config.shouldShow,
whenReady: () => this.whenReady(),
domain: config.domain,
})
const paths = collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath)
if (paths.length === 0) return
Expand Down
6 changes: 4 additions & 2 deletions src/routes/dashboard_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ interface DashboardRoutesOpts {
getApiController: () => ApiController | null
middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>
whenReady?: () => Promise<void>
domain?: string
}

/** Register dashboard routes. */
Expand All @@ -282,7 +283,7 @@ export function registerDashboardRoutes(opts: DashboardRoutesOpts) {
if (opts.whenReady) _whenReady = opts.whenReady
const base = dashboardPath.replace(/\/+$/, '')

router
const group = router
.group(() => {
registerDashboardPageRoutes(router, getDashboardController)
registerQueryRoutes(router, getApiController)
Expand All @@ -297,5 +298,6 @@ export function registerDashboardRoutes(opts: DashboardRoutesOpts) {
registerConfigAndFilterRoutes(router, getDashboardController)
})
.prefix(base)
.use(middleware)
if (opts.domain) group.domain(opts.domain)
group.use(middleware)
}
6 changes: 4 additions & 2 deletions src/routes/debug_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ interface DebugRoutesOpts {
getApp?: () => ApplicationService | null
middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>
whenReady?: () => Promise<void>
domain?: string
}

function registerDebugExplainRoute(
Expand Down Expand Up @@ -205,7 +206,7 @@ export function registerDebugRoutes(opts: DebugRoutesOpts) {
if (opts.whenReady) _whenReady = opts.whenReady
const base = debugEndpoint.replace(/\/+$/, '')

router
const group = router
.group(() => {
registerDebugConfigRoutes(router, getDebugController)
registerDebugQueryAndEventRoutes(router, getApiController)
Expand All @@ -215,5 +216,6 @@ export function registerDebugRoutes(opts: DebugRoutesOpts) {
registerDebugTraceRoutes(router, getApiController)
})
.prefix(base)
.use(middleware)
if (opts.domain) group.domain(opts.domain)
group.use(middleware)
}
7 changes: 6 additions & 1 deletion src/routes/register_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface RegisterRoutesOptions {
shouldShow?: (ctx: HttpContext) => boolean
/** Optional promise that resolves when controllers are initialized. */
whenReady?: () => Promise<void>
/** Optional domain restriction for all routes. */
domain?: string
}

/**
Expand All @@ -42,7 +44,8 @@ export function registerAllRoutes(options: RegisterRoutesOptions): void {
options.router,
options.statsEndpoint,
options.getStatsController,
middleware
middleware,
options.domain
)
}

Expand All @@ -56,6 +59,7 @@ export function registerAllRoutes(options: RegisterRoutesOptions): void {
getApp: options.getApp,
middleware,
whenReady: options.whenReady,
domain: options.domain,
})
}

Expand All @@ -67,6 +71,7 @@ export function registerAllRoutes(options: RegisterRoutesOptions): void {
getApiController: options.getApiController,
middleware,
whenReady: options.whenReady,
domain: options.domain,
})
}
}
17 changes: 16 additions & 1 deletion src/routes/router_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ export interface AdonisRoute {
use(middleware: unknown[]): void
}

/**
* Return type of `router.group()` supporting the chaining patterns
* used by server-stats route registration.
*
* AdonisJS allows chaining `.prefix()`, `.domain()`, and `.use()` in
* any order on a route group. This interface covers the combinations
* we use: `.prefix().use()`, `.prefix().domain().use()`, and
* `.domain().prefix().use()`.
*/
export interface AdonisRouteGroup {
prefix(path: string): AdonisRouteGroup
domain(host: string): AdonisRouteGroup
use(middleware: unknown[]): void
}

/**
* Minimal interface for the AdonisJS router used in route registration.
*
Expand All @@ -22,5 +37,5 @@ export interface AdonisRouter {
get(pattern: string, handler: (ctx: HttpContext) => unknown): AdonisRoute
post(pattern: string, handler: (ctx: HttpContext) => unknown): AdonisRoute
delete(pattern: string, handler: (ctx: HttpContext) => unknown): AdonisRoute
group(callback: () => void): { prefix(path: string): { use(middleware: unknown[]): void } }
group(callback: () => void): AdonisRouteGroup
}
32 changes: 20 additions & 12 deletions src/routes/stats_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,25 @@ export function registerStatsRoute(
router: AdonisRouter,
endpoint: string,
getController: () => ServerStatsController | null,
middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>
middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>,
domain?: string
) {
router
.get(endpoint, async (ctx: HttpContext) => {
const controller = getController()
if (!controller)
return ctx.response.serviceUnavailable({
error: 'Stats engine is starting up, please retry',
})
return controller.index(ctx)
})
.as('server-stats.api')
.use(middleware)
const handler = async (ctx: HttpContext) => {
const controller = getController()
if (!controller)
return ctx.response.serviceUnavailable({
error: 'Stats engine is starting up, please retry',
})
return controller.index(ctx)
}

if (domain) {
router
.group(() => {
router.get(endpoint, handler).as('server-stats.api').use(middleware)
})
.domain(domain)
} else {
router.get(endpoint, handler).as('server-stats.api').use(middleware)
}
}
27 changes: 27 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,30 @@ export interface ServerStatsConfig {
*/
dashboard?: boolean | DashboardConfig

/**
* Restrict all server-stats routes to a specific domain or subdomain.
*
* When set, routes are only matched when the request's `Host` header
* matches the given domain. Useful when admin routes live on a
* dedicated subdomain (e.g. `admin.example.com`).
*
* Supports dynamic subdomains using `:param` syntax
* (e.g. `':tenant.example.com'`).
*
* @example
* ```ts
* // Fixed subdomain
* domain: 'admin.example.com'
* ```
*
* @example
* ```ts
* // Dynamic subdomain
* domain: ':tenant.example.com'
* ```
*/
domain?: string

/**
* Advanced options for fine-tuning internal behavior.
*
Expand Down Expand Up @@ -988,4 +1012,7 @@ export interface ResolvedServerStatsConfig {

/** Whether verbose informational logging is enabled. Always present after `defineConfig()`. */
verbose: boolean

/** Optional domain restriction for all routes. */
domain?: string
}
147 changes: 147 additions & 0 deletions tests/domain_routing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { test } from '@japa/runner'
import { defineConfig } from '../src/define_config.js'

test.group('domain routing config', (group) => {
let originalLog: typeof console.log
group.setup(() => {
originalLog = console.log
console.log = () => {}
})
group.teardown(() => {
console.log = originalLog
})

test('defineConfig passes domain through to resolved config', ({ assert }) => {
const config = defineConfig({ domain: 'admin.example.com' })
assert.equal(config.domain, 'admin.example.com')
})

test('defineConfig leaves domain undefined when not set', ({ assert }) => {
const config = defineConfig({})
assert.isUndefined(config.domain)
})

test('defineConfig supports dynamic subdomain syntax', ({ assert }) => {
const config = defineConfig({ domain: ':tenant.example.com' })
assert.equal(config.domain, ':tenant.example.com')
})
})

test.group('domain routing - route registration', () => {
test('registerAllRoutes passes domain to sub-registrars', async ({ assert }) => {
const { registerAllRoutes } = await import('../src/routes/register_routes.js')

const registeredGroups: Array<{ prefix?: string; domain?: string; middleware?: unknown[] }> = []

const fakeRoute = {
as: () => fakeRoute,
where: () => fakeRoute,
use: () => {},
}

const fakeGroup = {
prefix(path: string) {
registeredGroups[registeredGroups.length - 1].prefix = path
return fakeGroup
},
domain(host: string) {
registeredGroups[registeredGroups.length - 1].domain = host
return fakeGroup
},
use(mw: unknown[]) {
registeredGroups[registeredGroups.length - 1].middleware = mw
},
}

const fakeRouter = {
get() {
return fakeRoute
},
post() {
return fakeRoute
},
delete() {
return fakeRoute
},
group(callback: () => void) {
registeredGroups.push({})
callback()
return fakeGroup
},
}

registerAllRoutes({
router: fakeRouter as any,
getApiController: () => null,
getStatsController: () => null,
getDebugController: () => null,
getDashboardController: () => null,
statsEndpoint: '/admin/api/server-stats',
debugEndpoint: '/admin/api/debug',
dashboardPath: '/__stats',
domain: 'admin.example.com',
})

// All groups should have domain set
assert.isTrue(registeredGroups.length >= 3, 'should have at least 3 groups (stats, debug, dashboard)')
for (const group of registeredGroups) {
assert.equal(group.domain, 'admin.example.com', `group with prefix "${group.prefix}" should have domain`)
}
})

test('registerAllRoutes does not set domain when not provided', async ({ assert }) => {
const { registerAllRoutes } = await import('../src/routes/register_routes.js')

const registeredGroups: Array<{ prefix?: string; domain?: string }> = []

const fakeRoute = {
as: () => fakeRoute,
where: () => fakeRoute,
use: () => {},
}

const fakeGroup = {
prefix(path: string) {
registeredGroups[registeredGroups.length - 1].prefix = path
return fakeGroup
},
domain(host: string) {
registeredGroups[registeredGroups.length - 1].domain = host
return fakeGroup
},
use() {},
}

const fakeRouter = {
get() {
return fakeRoute
},
post() {
return fakeRoute
},
delete() {
return fakeRoute
},
group(callback: () => void) {
registeredGroups.push({})
callback()
return fakeGroup
},
}

registerAllRoutes({
router: fakeRouter as any,
getApiController: () => null,
getStatsController: () => null,
getDebugController: () => null,
getDashboardController: () => null,
debugEndpoint: '/admin/api/debug',
dashboardPath: '/__stats',
})

// No group should have a domain set
for (const group of registeredGroups) {
assert.isUndefined(group.domain, `group with prefix "${group.prefix}" should not have domain`)
}
})
})