File size: 5,650 Bytes
a1b6014 | 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 | import { reconnectBackoffDelayMs } from '@hermes/shared'
import type { HermesConnection } from '@/global'
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client'
/** Resolve the ACTIVE backend's connection descriptor, (connectionId,
* profile)-scoped β mirroring how store/profile resolves $connection: a
* registry agent's descriptor comes from getConnectionFor (its SOURCE
* connection), everything else from the profile-keyed local pool. The
* getConnectionFor bridge is optional (older Desktop mains); without it the
* profile-scoped pool lookup is the best available answer.
*
* Both branches are IPC round-trips into the main process with no timeout of
* their own (#93454) β a wedged main-process round-trip otherwise hangs
* pluginSocket's connect() forever instead of falling back to the polling
* fallback every consumer already has. Bound the same way store/gateway's
* openSecondary bounds the same *For/plain pair.
*
* Exported for tests. */
export async function activeConnection(): Promise<HermesConnection> {
const getConnectionFor = window.hermesDesktop.getConnectionFor
const connectionId = getApiRequestConnection()
const profile = getApiRequestProfile()
if (connectionId && getConnectionFor) {
return withTimeout(
getConnectionFor({ connectionId, profile }),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${profile}"`
)
}
return withTimeout(
window.hermesDesktop.getConnection(profile),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${profile}"`
)
}
/** Options for a plugin REST call β mirrors the app's own `hermesDesktop.api`
* shape, minus the path (which is namespace-derived). */
export interface PluginRestOptions {
method?: string
body?: unknown
/** Single-file multipart upload (see HermesApiRequest.upload). */
upload?: { filename: string; contentType?: string; bytes: ArrayBuffer }
timeoutMs?: number
}
// Normalize `path` to a leading-slash suffix relative to `/api/plugins/<id>`.
// The namespace is the boundary β reject `..` so a relative segment can't
// normalize out into another plugin's API or a core route. Check the path
// portion only (before any query/hash).
function pluginPathSuffix(caller: string, path: string): string {
const suffix = path.startsWith('/') ? path : `/${path}`
if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) {
throw new Error(`${caller}: illegal path traversal in "${path}"`)
}
return suffix
}
/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's
* own backend namespace β `path` is relative to `/api/plugins/<pluginId>`
* ('/board' β `/api/plugins/kanban/board`), so a plugin can't address another
* plugin's API or a core route through it. Profile-aware like every desktop
* REST call. Broader reach (core endpoints, another namespace) is the future
* declared-capability seam; today the namespace IS the boundary. */
export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
if (!window.hermesDesktop?.api) {
throw new Error('Hermes desktop bridge unavailable')
}
const suffix = pluginPathSuffix('pluginRest', path)
return hermesApi<T>({
path: `/api/plugins/${pluginId}${suffix}`,
method: opts.method,
body: opts.body,
upload: opts.upload,
timeoutMs: opts.timeoutMs,
...profileScoped()
})
}
/** The plugin WebSocket door β the live twin of `pluginRest`, scoped the same
* way: `path` is relative to `/api/plugins/<pluginId>` ('/events' β the
* plugin's own event stream). Token-mode backends auth via the same query
* credential the app's own sockets use; OAuth remotes resolve null (callers
* keep their polling fallback β every consumer must have one anyway, since a
* socket can drop). Auto-reconnects with backoff until disposed. */
export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void {
const suffix = pluginPathSuffix('pluginSocket', path)
let socket: null | WebSocket = null
let disposed = false
let attempt = 0
const connect = async () => {
const connection = await activeConnection().catch(() => null)
// No bridge / OAuth cookie auth (WS tickets are single-use, core-managed):
// stay on the polling fallback rather than half-working.
if (disposed || !connection || connection.authMode === 'oauth') {
return
}
const base = connection.baseUrl.replace(/^http/, 'ws')
const join = suffix.includes('?') ? '&' : '?'
socket = new WebSocket(
`${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}`
)
socket.onmessage = event => {
attempt = 0
try {
onMessage(JSON.parse(String(event.data)))
} catch {
// Non-JSON frame β plugin streams are JSON by contract; skip it.
}
}
socket.onclose = () => {
socket = null
if (!disposed) {
// Full-jitter exponential backoff: same rationale as the gateway
// socket reconnect loops β an immediate-retry loop across many
// desktop clients floods the gateway with connection attempts
// during a restart.
window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 }))
attempt += 1
}
}
}
void connect()
return () => {
disposed = true
socket?.close()
}
}
|