Skip to content

Commit 3e5b0d1

Browse files
committed
feat(basepath): add runtime env support for subpath deployments
1 parent d69f875 commit 3e5b0d1

File tree

12 files changed

+272
-23
lines changed

12 files changed

+272
-23
lines changed

.changeset/basepath-support.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
'@hyperdx/app': minor
33
'@hyperdx/api': minor
4+
'@hyperdx/common-utils': minor
45
---
56

67
feat: Add basePath support for subpath deployments via environment variables

.env

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ HYPERDX_APP_PORT=8080
2020
HYPERDX_APP_URL=http://localhost
2121
HYPERDX_LOG_LEVEL=debug
2222
HYPERDX_OPAMP_PORT=4320
23-
HYPERDX_BASE_PATH=/hyperdx
24-
HYPERDX_API_BASE_PATH=/hyperdx/api
25-
HYPERDX_OTEL_BASE_PATH=/hyperdx/otel
23+
24+
# Subpath support (leave empty for root)
25+
HYPERDX_BASE_PATH=
26+
HYPERDX_API_BASE_PATH=
27+
HYPERDX_OTEL_BASE_PATH=
2628

2729
# Otel/Clickhouse config
2830
HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE=default

packages/api/src/api-app.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath';
12
import compression from 'compression';
23
import MongoStore from 'connect-mongo';
34
import express from 'express';
@@ -20,7 +21,7 @@ import passport from './utils/passport';
2021

2122
const app: express.Application = express();
2223

23-
const API_BASE_PATH = process.env.HYPERDX_API_BASE_PATH || '';
24+
const API_BASE_PATH = getApiBasePath();
2425

2526
const sess: session.SessionOptions & { cookie: session.CookieOptions } = {
2627
resave: false,
@@ -83,10 +84,10 @@ if (config.USAGE_STATS_ENABLED) {
8384
// ---------------------------------------------------------------------
8485
if (API_BASE_PATH) {
8586
const apiRouter = express.Router();
86-
87+
8788
// PUBLIC ROUTES
8889
apiRouter.use('/', routers.rootRouter);
89-
90+
9091
// PRIVATE ROUTES
9192
apiRouter.use('/alerts', isUserAuthenticated, routers.alertsRouter);
9293
apiRouter.use('/dashboards', isUserAuthenticated, routers.dashboardRouter);
@@ -96,9 +97,13 @@ if (API_BASE_PATH) {
9697
apiRouter.use('/connections', isUserAuthenticated, connectionsRouter);
9798
apiRouter.use('/sources', isUserAuthenticated, sourcesRouter);
9899
apiRouter.use('/saved-search', isUserAuthenticated, savedSearchRouter);
99-
apiRouter.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);
100+
apiRouter.use(
101+
'/clickhouse-proxy',
102+
isUserAuthenticated,
103+
clickhouseProxyRouter,
104+
);
100105
apiRouter.use('/api/v2', externalRoutersV2);
101-
106+
102107
// Only initialize Swagger in development or if explicitly enabled
103108
if (
104109
process.env.NODE_ENV !== 'production' &&
@@ -116,7 +121,7 @@ if (API_BASE_PATH) {
116121
);
117122
});
118123
}
119-
124+
120125
app.use(API_BASE_PATH, apiRouter);
121126
} else {
122127
// PUBLIC ROUTES
@@ -132,7 +137,7 @@ if (API_BASE_PATH) {
132137
app.use('/sources', isUserAuthenticated, sourcesRouter);
133138
app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
134139
app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);
135-
140+
136141
// TODO: Separate external API routers from internal routers
137142
// ---------------------------------------------------------------------
138143
// ----------------------- External Routers ----------------------------

packages/api/src/opamp/app.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,19 @@ import { opampController } from '@/opamp/controllers/opampController';
66
// Create Express application
77
const app = express();
88

9-
const OTEL_BASE_PATH = process.env.HYPERDX_OTEL_BASE_PATH || '';
10-
119
app.disable('x-powered-by');
1210

1311
// Special body parser setup for OpAMP
1412
app.use(
15-
`${OTEL_BASE_PATH}/v1/opamp`,
13+
'/v1/opamp',
1614
express.raw({
1715
type: 'application/x-protobuf',
1816
limit: '10mb',
1917
}),
2018
);
2119

2220
// OpAMP endpoint
23-
app.post(`${OTEL_BASE_PATH}/v1/opamp`, opampController.handleOpampMessage.bind(opampController));
21+
app.post('/v1/opamp', opampController.handleOpampMessage.bind(opampController));
2422

2523
// Health check endpoint
2624
app.get('/health', (req, res) => {

packages/app/next.config.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { configureRuntimeEnv } = require('next-runtime-env/build/configure');
22
const { version } = require('./package.json');
3+
const { getFrontendBasePath } = require('@hyperdx/common-utils/dist/basePath');
34

45
configureRuntimeEnv();
56

@@ -9,7 +10,7 @@ const withNextra = require('nextra')({
910
});
1011

1112
module.exports = {
12-
basePath: process.env.HYPERDX_BASE_PATH || '',
13+
basePath: getFrontendBasePath(),
1314
experimental: {
1415
instrumentationHook: true,
1516
// External packages to prevent bundling issues with Next.js 14
@@ -58,8 +59,8 @@ module.exports = {
5859
productionBrowserSourceMaps: false,
5960
...(process.env.NEXT_OUTPUT_STANDALONE === 'true'
6061
? {
61-
output: 'standalone',
62-
}
62+
output: 'standalone',
63+
}
6364
: {}),
6465
}),
6566
};

packages/app/pages/api/[...all].ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { NextApiRequest, NextApiResponse } from 'next';
22
import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware';
3+
import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath';
34

45
const DEFAULT_SERVER_URL = `http://127.0.0.1:${process.env.HYPERDX_API_PORT}`;
5-
const API_BASE_PATH = process.env.HYPERDX_API_BASE_PATH || '';
6+
const API_BASE_PATH = getApiBasePath();
67

78
export const config = {
89
api: {

packages/app/src/api.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import Router from 'next/router';
33
import type { HTTPError, Options, ResponsePromise } from 'ky';
44
import ky from 'ky-universal';
5+
import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath';
56
import type { Alert } from '@hyperdx/common-utils/dist/types';
67
import type { UseQueryOptions } from '@tanstack/react-query';
78
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
@@ -48,10 +49,7 @@ export function loginHook(request: Request, options: any, response: Response) {
4849
}
4950

5051
// Get basePath from runtime environment
51-
const getApiPrefix = () => {
52-
const basePath = process.env.HYPERDX_BASE_PATH || '';
53-
return `${basePath}/api`;
54-
};
52+
const getApiPrefix = () => getApiBasePath();
5553

5654
export const server = ky.create({
5755
prefixUrl: getApiPrefix(),

packages/app/src/clickhouse.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// please move app-specific functions elsewhere in the app
66
// ================================
77

8+
import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath';
89
import {
910
chSql,
1011
ClickhouseClientOptions,
@@ -20,7 +21,7 @@ import { getLocalConnections } from '@/connection';
2021
import api from './api';
2122
import { DEFAULT_QUERY_TIMEOUT } from './defaults';
2223

23-
const PROXY_CLICKHOUSE_HOST = `${process.env.HYPERDX_BASE_PATH || ''}/api/clickhouse-proxy`;
24+
const PROXY_CLICKHOUSE_HOST = `${getApiBasePath()}/clickhouse-proxy`;
2425

2526
export const getClickhouseClient = (
2627
options: ClickhouseClientOptions = {},
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
getApiBasePath,
3+
getFrontendBasePath,
4+
getOtelBasePath,
5+
joinPath,
6+
} from '../basePath';
7+
8+
describe('basePath utilities', () => {
9+
describe('getFrontendBasePath', () => {
10+
it('returns empty string if no env var', () => {
11+
delete process.env.HYPERDX_BASE_PATH;
12+
expect(getFrontendBasePath()).toBe('');
13+
});
14+
15+
it('returns normalized path for valid input', () => {
16+
process.env.HYPERDX_BASE_PATH = '/hyperdx';
17+
expect(getFrontendBasePath()).toBe('/hyperdx');
18+
19+
process.env.HYPERDX_BASE_PATH = 'hyperdx/';
20+
expect(getFrontendBasePath()).toBe('/hyperdx');
21+
22+
process.env.HYPERDX_BASE_PATH = '/hyperdx/';
23+
expect(getFrontendBasePath()).toBe('/hyperdx');
24+
});
25+
26+
it('returns empty for invalid paths', () => {
27+
process.env.HYPERDX_BASE_PATH = '/../invalid';
28+
expect(getFrontendBasePath()).toBe('');
29+
30+
process.env.HYPERDX_BASE_PATH = 'http://example.com';
31+
expect(getFrontendBasePath()).toBe('');
32+
});
33+
});
34+
35+
describe('getApiBasePath', () => {
36+
it('defaults to /api if no env var', () => {
37+
delete process.env.HYPERDX_API_BASE_PATH;
38+
expect(getApiBasePath()).toBe('/api');
39+
});
40+
41+
it('uses env var if set', () => {
42+
process.env.HYPERDX_API_BASE_PATH = '/hyperdx/api';
43+
expect(getApiBasePath()).toBe('/hyperdx/api');
44+
});
45+
46+
it('normalizes input', () => {
47+
process.env.HYPERDX_API_BASE_PATH = 'hyperdx/api/';
48+
expect(getApiBasePath()).toBe('/hyperdx/api');
49+
});
50+
});
51+
52+
describe('getOtelBasePath', () => {
53+
it('returns empty if no env var', () => {
54+
delete process.env.HYPERDX_OTEL_BASE_PATH;
55+
expect(getOtelBasePath()).toBe('');
56+
});
57+
58+
it('returns normalized path', () => {
59+
process.env.HYPERDX_OTEL_BASE_PATH = '/hyperdx/otel';
60+
expect(getOtelBasePath()).toBe('/hyperdx/otel');
61+
});
62+
});
63+
64+
describe('joinPath', () => {
65+
it('joins empty base with relative', () => {
66+
expect(joinPath('', '/test')).toBe('/test');
67+
});
68+
69+
it('joins valid base and relative', () => {
70+
expect(joinPath('/hyperdx', '/api')).toBe('/hyperdx/api');
71+
expect(joinPath('/hyperdx', 'api')).toBe('/hyperdx/api');
72+
});
73+
74+
it('normalizes joined path', () => {
75+
expect(joinPath('/hyperdx/', '/api/')).toBe('/hyperdx/api');
76+
});
77+
});
78+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import path from 'path';
2+
3+
/**
4+
* Normalizes a base path: ensures it starts with '/', removes trailing '/',
5+
* and validates against common issues like path traversal or absolute URLs.
6+
* @param basePath - The raw base path from env var
7+
* @returns Normalized path or empty string if invalid
8+
*/
9+
function normalizeBasePath(basePath: string | undefined): string {
10+
if (!basePath || typeof basePath !== 'string') {
11+
return '';
12+
}
13+
14+
const trimmed = basePath.trim();
15+
if (!trimmed) {
16+
return '';
17+
}
18+
19+
// Validate: prevent full URLs on original trimmed input
20+
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
21+
console.warn(`Invalid base path detected: ${basePath}. Using empty path.`);
22+
return '';
23+
}
24+
25+
// Ensure leading slash
26+
let normalized = trimmed;
27+
if (!normalized.startsWith('/')) {
28+
normalized = '/' + normalized;
29+
}
30+
31+
// Remove trailing slash if present (except for root '/')
32+
if (normalized.endsWith('/') && normalized !== '/') {
33+
normalized = normalized.slice(0, -1);
34+
}
35+
36+
// Validate: prevent path traversal
37+
if (normalized.includes('..')) {
38+
console.warn(`Invalid base path detected: ${basePath}. Using empty path.`);
39+
return '';
40+
}
41+
42+
return normalized;
43+
}
44+
45+
/**
46+
* Joins a base path with a relative path, normalizing the result.
47+
* @param base - Normalized base path
48+
* @param relative - Relative path to join
49+
* @returns Full joined path
50+
*/
51+
export function joinPath(base: string, relative: string): string {
52+
if (!base) return normalizeBasePath(relative);
53+
if (!relative.startsWith('/')) relative = '/' + relative;
54+
let joined = path.posix.join(base, relative);
55+
if (!joined.startsWith('/')) joined = '/' + joined;
56+
if (joined.endsWith('/') && joined !== '/') joined = joined.slice(0, -1);
57+
return joined;
58+
}
59+
60+
/**
61+
* Gets the normalized frontend base path from HYPERDX_BASE_PATH env var.
62+
*/
63+
export function getFrontendBasePath(): string {
64+
return normalizeBasePath(process.env.HYPERDX_BASE_PATH);
65+
}
66+
67+
/**
68+
* Gets the normalized API base path from HYPERDX_API_BASE_PATH env var.
69+
* Defaults to '/api' if empty, but allows override.
70+
*/
71+
export function getApiBasePath(): string {
72+
let apiPath = process.env.HYPERDX_API_BASE_PATH || '/api';
73+
if (!apiPath.startsWith('/')) apiPath = '/' + apiPath;
74+
return joinPath(normalizeBasePath(apiPath), '');
75+
}
76+
77+
/**
78+
* Gets the normalized OTEL base path from HYPERDX_OTEL_BASE_PATH env var.
79+
*/
80+
export function getOtelBasePath(): string {
81+
return normalizeBasePath(process.env.HYPERDX_OTEL_BASE_PATH);
82+
}

0 commit comments

Comments
 (0)