File size: 4,552 Bytes
f76c374 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | export type GatewayAuthMode = 'oauth' | 'token' | (string & {})
export interface GatewayWsConnection {
authMode?: GatewayAuthMode | null
profile?: null | string
wsUrl: string
}
export interface ResolveGatewayWsUrlDeps {
/**
* Returns a fresh WebSocket URL for the selected backend/profile.
* OAuth-gated gateways use single-use tickets, so callers should mint
* immediately before opening the socket.
*/
getGatewayWsUrl?: (profile?: null | string) => Promise<GatewayWsUrlResult>
}
export type GatewayWsUrlResult =
| string
| { ok: true; wsUrl: string }
| { error: string; needsOauthLogin?: boolean; ok: false }
export class GatewayReauthRequiredError extends Error {
readonly needsOauthLogin = true
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'GatewayReauthRequiredError'
}
}
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
return (
error instanceof GatewayReauthRequiredError ||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
)
}
export async function resolveGatewayWsUrl(deps: ResolveGatewayWsUrlDeps, conn: GatewayWsConnection): Promise<string> {
const mint = deps.getGatewayWsUrl
const profile = conn.profile ?? null
if (conn.authMode === 'oauth') {
if (!mint) {
throw new Error('This Desktop build cannot refresh OAuth WebSocket tickets. Update Hermes Desktop and try again.')
}
try {
const result = await mint(profile)
if (typeof result === 'string') {
return result
}
if (result.ok) {
return result.wsUrl
}
if (result.needsOauthLogin) {
throw new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.',
{ cause: new Error(result.error) }
)
}
throw new Error(result.error || 'Could not refresh the remote gateway WebSocket ticket.')
} catch (error) {
if (isGatewayReauthRequired(error)) {
throw error instanceof GatewayReauthRequiredError
? error
: new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.',
{ cause: error }
)
}
throw error
}
}
if (mint) {
const fresh = await mint(profile).catch(() => null)
if (typeof fresh === 'string') {
return fresh
}
if (fresh?.ok) {
return fresh.wsUrl
}
}
return conn.wsUrl
}
export type WebSocketAuthParam = readonly [name: string, value: string]
export interface HermesWebSocketUrlOptions {
/** Dashboard or gateway-relative endpoint path, e.g. "/api/ws". */
path: string
/** Optional URL prefix when the backend is reverse-proxied below a subpath. */
basePath?: string
/** Query auth pair, usually ["token", value] or ["ticket", value]. */
authParam?: WebSocketAuthParam
/** Extra query params merged before auth. */
params?: Record<string, string>
/** Browser protocol string such as "https:"; defaults to window.location.protocol. */
protocol?: string
/** Host with optional port; defaults to window.location.host. */
host?: string
}
function readWindowLocation(): { host: string; protocol: string } {
if (typeof window === 'undefined') {
return { host: '', protocol: 'http:' }
}
return { host: window.location.host, protocol: window.location.protocol }
}
function normalizeBasePath(basePath: string | undefined): string {
if (!basePath) {
return ''
}
const withLead = basePath.startsWith('/') ? basePath : `/${basePath}`
return withLead.replace(/\/+$/, '')
}
function normalizeEndpointPath(path: string): string {
return path.startsWith('/') ? path : `/${path}`
}
export function buildHermesWebSocketUrl(options: HermesWebSocketUrlOptions): string {
const loc = readWindowLocation()
const protocol = options.protocol ?? loc.protocol
const host = options.host ?? loc.host
const wsScheme = protocol === 'https:' || protocol === 'wss:' ? 'wss:' : 'ws:'
const qs = new URLSearchParams(options.params ?? {})
if (options.authParam) {
const [name, value] = options.authParam
qs.set(name, value)
}
const query = qs.toString()
const suffix = query ? `?${query}` : ''
return `${wsScheme}//${host}${normalizeBasePath(options.basePath)}${normalizeEndpointPath(options.path)}${suffix}`
}
|