Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 packages/dashboard-server/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export interface RouteContext {
resolveRelaycastConfig: () => RelaycastConfig | null;
setRelayApiKey: (apiKey: string) => void;
setRelayAgentIdentity: (token: string, name: string) => void;
clearCachedAgentToken: () => void;
getRelaycastSnapshot: () => Promise<DashboardSnapshot>;
getRelaycastChannels: () => Promise<{ channels: DashboardChannel[]; archivedChannels: DashboardChannel[] }>;
sendRelaycastMessage: (params: { to: string; message: string; from?: string; thread?: string }) => Promise<
Expand Down
7 changes: 7 additions & 0 deletions packages/dashboard-server/src/proxy-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { createSendStrategy } from './lib/send-strategy.js';
import type { SendStrategy } from './lib/send-strategy.js';
import { DASHBOARD_DISPLAY_NAME } from './relaycast-provider-types.js';
import { clearRegistrationCache } from './relaycast-provider-helpers.js';
import { resolveIdentity } from './lib/identity.js';
import type {
DashboardMode,
Expand Down Expand Up @@ -224,6 +225,11 @@ export function createServer(options: DashboardServerOptions = {}): DashboardSer
inMemoryAgentToken = token;
inMemoryAgentName = name;
};
const clearCachedAgentToken = (): void => {
inMemoryAgentToken = undefined;
inMemoryAgentName = undefined;
clearRegistrationCache();
};
const { getSpawnedAgents, getLocalAgentNames } = createSpawnedAgentsCaches({
brokerProxyEnabled,
relayUrl,
Expand Down Expand Up @@ -385,6 +391,7 @@ export function createServer(options: DashboardServerOptions = {}): DashboardSer
resolveRelaycastConfig,
setRelayApiKey,
setRelayAgentIdentity,
clearCachedAgentToken,
getRelaycastSnapshot,
getRelaycastChannels,
sendRelaycastMessage,
Expand Down
9 changes: 9 additions & 0 deletions packages/dashboard-server/src/relaycast-provider-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ function registrationCacheKey(baseUrl: string | undefined, apiKey: string, agent
return `${baseUrl ?? ''}|${apiKey}|${agentName.toLowerCase()}`;
}

/**
* Clear all entries from the registration cache so the next
* getDashboardAgentToken / createRelaycastClient call performs
* a fresh registerOrRotate against the server.
*/
export function clearRegistrationCache(): void {
registrationCache.clear();
}

/**
* Register (or retrieve cached) agent token via registerOrRotate.
* Shared by createRelaycastClient and getDashboardAgentToken to ensure
Expand Down
8 changes: 8 additions & 0 deletions packages/dashboard-server/src/routes/relay-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ export function registerRelayConfigRoutes(app: Express, ctx: RouteContext): void
}
}

// When refresh=true, clear cached agent token so we re-register and get
// a fresh token. This handles cases where the token was rotated externally
// (e.g. by another process calling registerOrRotate for the same agent).
const forceRefresh = req.query.refresh === 'true';
if (forceRefresh) {
ctx.clearCachedAgentToken();
}

const config = ctx.resolveRelaycastConfig();
if (!config) {
res.status(503).json({
Expand Down
96 changes: 80 additions & 16 deletions packages/dashboard/src/providers/RelayConfigProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { RelayProvider } from '@relaycast/react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { RelayProvider, useWebSocket } from '@relaycast/react';

interface RelayConfigResponse {
success: boolean;
Expand Down Expand Up @@ -35,37 +35,100 @@ export function useRelayConfigStatus(): RelayConfigStatus {
/** Default channels the dashboard agent should subscribe to via WebSocket */
const DEFAULT_CHANNELS = ['general'];

/** How long the WS must stay in reconnecting state before we try a token refresh */
const RECONNECT_STALE_MS = 10_000;

async function fetchRelayConfig(refresh = false): Promise<RelayConfigResponse | null> {
const url = refresh ? '/api/relay-config?refresh=true' : '/api/relay-config';
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) return null;
const payload = await response.json() as RelayConfigResponse;
if (!payload?.success || !payload.baseUrl || !payload.apiKey || !payload.agentToken) return null;
return payload;
}

/**
* Child component that monitors WebSocket connection status.
* When the connection stays in 'reconnecting' state for too long, it requests
* a fresh token from the server and triggers a config update.
*/
function TokenRefreshMonitor({ onTokenRefresh }: { onTokenRefresh: (config: RelayConfigResponse) => void }) {
const { status } = useWebSocket();
const reconnectingSinceRef = useRef<number | null>(null);
const refreshInFlightRef = useRef(false);

useEffect(() => {
if (status === 'reconnecting') {
if (reconnectingSinceRef.current === null) {
reconnectingSinceRef.current = Date.now();
}

const elapsed = Date.now() - reconnectingSinceRef.current;
if (elapsed >= RECONNECT_STALE_MS && !refreshInFlightRef.current) {
refreshInFlightRef.current = true;
fetchRelayConfig(true)
.then((payload) => {
if (payload) {
onTokenRefresh(payload);
}
})
.catch(() => {})
.finally(() => {
refreshInFlightRef.current = false;
});
} else if (elapsed < RECONNECT_STALE_MS) {
// Schedule a check after the threshold
const timer = setTimeout(() => {
if (reconnectingSinceRef.current !== null && !refreshInFlightRef.current) {
refreshInFlightRef.current = true;
fetchRelayConfig(true)
.then((payload) => {
if (payload) {
onTokenRefresh(payload);
}
})
.catch(() => {})
.finally(() => {
refreshInFlightRef.current = false;
});
}
}, RECONNECT_STALE_MS - elapsed);
return () => clearTimeout(timer);
}
} else {
reconnectingSinceRef.current = null;
}
}, [status, onTokenRefresh]);

return null;
}

export function RelayConfigProvider({ children }: RelayConfigProviderProps) {
const [config, setConfig] = useState<RelayConfigResponse | null>(null);
const [loaded, setLoaded] = useState(false);

useEffect(() => {
let cancelled = false;

void fetch('/api/relay-config', { credentials: 'include' })
.then(async (response) => {
if (!response.ok) return null;
return response.json() as Promise<RelayConfigResponse>;
})
void fetchRelayConfig()
.then((payload) => {
if (cancelled || !payload?.success) return;
if (!payload.baseUrl || !payload.apiKey || !payload.agentToken) return;
setConfig(payload);
})
.catch(() => {
// No relay-config is a valid local fallback.
if (cancelled) return;
if (payload) setConfig(payload);
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setLoaded(true);
}
if (!cancelled) setLoaded(true);
});

return () => {
cancelled = true;
};
}, []);

const handleTokenRefresh = useCallback((newConfig: RelayConfigResponse) => {
setConfig(newConfig);
}, []);

const configured = Boolean(config?.baseUrl && config.apiKey && config.agentToken);
const providerConfig = useMemo(() => {
if (configured) {
Expand Down Expand Up @@ -101,6 +164,7 @@ export function RelayConfigProvider({ children }: RelayConfigProviderProps) {
agentToken={providerConfig.agentToken}
channels={channels}
>
{configured && <TokenRefreshMonitor onTokenRefresh={handleTokenRefresh} />}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{children as any}
</RelayProvider>
Expand Down
Loading