export const DEFAULT_SENTRY_HOST = 'sentry.io'
export const OFFICIAL_SENTRY_HOSTS = ['sentry.io', 'us.sentry.io', 'de.sentry.io'] as const
const HOSTNAME_RE =
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))+$/i
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1'])
export function trimString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/**
* Normalize a Sentry API host to `hostname` or `hostname:port`.
* Accepts `sentry.io`, `https://us.sentry.io`, or a self-hosted hostname.
*/
export function normalizeSentryHost(value?: unknown): string {
const raw = trimString(value) || DEFAULT_SENTRY_HOST
const withScheme = raw.includes('://') ? raw : `https://${raw}`
let url: URL
try {
url = new URL(withScheme)
} catch {
throw new Error(`Sentry host is invalid: ${JSON.stringify(value)}`)
}
if (url.username || url.password) {
throw new Error('Sentry host must not include credentials.')
}
if (url.pathname !== '/' && url.pathname !== '') {
throw new Error('Sentry host must not include a path.')
}
if (url.search || url.hash) {
throw new Error('Sentry host must not include a query or hash.')
}
const hostname = url.hostname.toLowerCase()
const isOfficial = (OFFICIAL_SENTRY_HOSTS as readonly string[]).includes(hostname)
const isLocal = LOCAL_HOSTS.has(hostname)
if (!isOfficial && !isLocal && !HOSTNAME_RE.test(hostname)) {
throw new Error(
`Sentry host must be a hostname such as "sentry.io" or "sentry.example.com" (got ${JSON.stringify(value)}).`,
)
}
if (isOfficial && url.protocol !== 'https:') {
throw new Error('Official Sentry hosts must use https.')
}
if (!isOfficial && !isLocal && url.protocol !== 'https:') {
throw new Error('Self-hosted Sentry hosts must use https (http is allowed only for localhost).')
}
const port = url.port ? `:${url.port}` : ''
return `${hostname}${port}`
}
export function sentryApiBaseUrl(host?: unknown): string {
const normalized = normalizeSentryHost(host)
const hostname = normalized.split(':')[0] ?? normalized
const isLocal = LOCAL_HOSTS.has(hostname)
const protocol = isLocal ? 'http' : 'https'
return `${protocol}://${normalized}`
}
export function isSafeHttpMethod(method: string): boolean {
switch (method.toUpperCase()) {
case 'GET':
case 'HEAD':
case 'OPTIONS':
return true
default:
return false
}
}
export function runHostHelperSelfCheck(): { ok: true; checks: number } {
const cases: Array<[unknown, string]> = [
[undefined, DEFAULT_SENTRY_HOST],
['sentry.io', 'sentry.io'],
['https://us.sentry.io', 'us.sentry.io'],
['https://de.sentry.io/', 'de.sentry.io'],
['Sentry.Example.com', 'sentry.example.com'],
['localhost:9000', 'localhost:9000'],
]
for (const [input, expected] of cases) {
const actual = normalizeSentryHost(input)
if (actual !== expected) {
throw new Error(`normalizeSentryHost(${JSON.stringify(input)}) => ${actual}, expected ${expected}`)
}
}
if (sentryApiBaseUrl() !== 'https://sentry.io') {
throw new Error('Default Sentry API base URL must be https://sentry.io')
}
if (sentryApiBaseUrl('us.sentry.io') !== 'https://us.sentry.io') {
throw new Error('Regional Sentry API base URL must be https://us.sentry.io')
}
if (sentryApiBaseUrl('localhost:9000') !== 'http://localhost:9000') {
throw new Error('Local self-hosted Sentry must use http://localhost:9000')
}
const rejects = ['https://evil.example/path', 'https://user:pass@sentry.io', 'ftp://sentry.io']
for (const input of rejects) {
let threw = false
try {
normalizeSentryHost(input)
} catch {
threw = true
}
if (!threw) {
throw new Error(`normalizeSentryHost should reject ${JSON.stringify(input)}`)
}
}
return { ok: true, checks: cases.length + 3 + rejects.length }
}