feat: enhance relay discovery logic with new interfaces and normalization functions

Kim committed Apr 13, 2026 at 19:00 UTC 9bfbb75d4bd5eac8c1d2926a34b5b24e9e26dd29
3 files changed +174 -55
frontend/src/components/ServerListView.tsx
+146 -48
@@ -30,6 +30,19 @@ interface RelayDomainResponse {
30 release_version?: string;
31 }
32
33 +interface RelayDiscoveryDescriptor {
34 + api_https_addr?: string;
35 +}
36 +
37 +interface RelayDiscoveryResponse {
38 + relays?: RelayDiscoveryDescriptor[];
39 +}
40 +
41 +interface KnownRelay {
42 + relayURL: string;
43 + isCurrent: boolean;
44 +}
45 +
46 const OFFICIAL_REGISTRY_SOURCE_URL =
47 "https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json";
48 const REPOSITORY_URL = "https://github.com/gosuda/portal-tunnel";
@@ -57,6 +70,47 @@ async function loadRelayReleaseVersion(
70 }
71 }
72
73 +function normalizeRelayURL(relayURL: string | undefined): string {
74 + return typeof relayURL === "string" ? relayURL.trim() : "";
75 +}
76 +
77 +function normalizeKnownRelays(
78 + relays: RelayDiscoveryDescriptor[] | undefined,
79 + currentRelayURL: string
80 +): KnownRelay[] {
81 + const seen = new Set<string>();
82 + const knownRelays: KnownRelay[] = [];
83 +
84 + relays?.forEach((relay) => {
85 + const relayURL = normalizeRelayURL(relay.api_https_addr);
86 + if (relayURL === "" || seen.has(relayURL)) {
87 + return;
88 + }
89 +
90 + seen.add(relayURL);
91 + knownRelays.push({
92 + relayURL,
93 + isCurrent: relayURL === currentRelayURL,
94 + });
95 + });
96 +
97 + if (currentRelayURL !== "" && !seen.has(currentRelayURL)) {
98 + knownRelays.push({
99 + relayURL: currentRelayURL,
100 + isCurrent: true,
101 + });
102 + }
103 +
104 + knownRelays.sort((a, b) => {
105 + if (a.isCurrent !== b.isCurrent) {
106 + return a.isCurrent ? -1 : 1;
107 + }
108 + return a.relayURL.localeCompare(b.relayURL);
109 + });
110 +
111 + return knownRelays;
112 +}
113 +
114 interface ServerListViewProps {
115 title?: string;
116 searchQuery: string;
@@ -146,15 +200,21 @@ export function ServerListView({
200 onLogout,
201 }: ServerListViewProps) {
202 const [showFilterModal, setShowFilterModal] = useState(false);
149 - const [connectedRelayReleaseVersion, setConnectedRelayReleaseVersion] =
150 - useState("");
203 + const [relayReleaseVersions, setRelayReleaseVersions] = useState<
204 + Record<string, string>
205 + >({});
206 + const [knownRelays, setKnownRelays] = useState<KnownRelay[]>([]);
207 + const [relayDiscoveryLoading, setRelayDiscoveryLoading] = useState(
208 + () => !isAdmin
209 + );
210 + const [relayDiscoveryMessage, setRelayDiscoveryMessage] = useState("");
211 const [selectedIdentityKeys, setSelectedIdentityKeys] = useState<Set<string>>(
212 new Set()
213 );
214 const serverItems = filteredServers as ListServer[];
215 const favoriteIds = useMemo(() => new Set(favorites), [favorites]);
216 const showLandingHero = !isAdmin && landingPageEnabled;
157 - const connectedRelayURL = useMemo(() => readCurrentOrigin(), []);
217 + const currentRelayURL = useMemo(() => readCurrentOrigin(), []);
218
219 const handleToggleSelect = (identityKey: string) => {
220 setSelectedIdentityKeys((prev) => {
@@ -228,24 +288,50 @@ export function ServerListView({
288 if (isAdmin) {
289 return;
290 }
231 - if (connectedRelayURL.trim().length === 0) {
232 - setConnectedRelayReleaseVersion("");
233 - return;
234 - }
235 -
291 let cancelled = false;
237 - setConnectedRelayReleaseVersion("");
292 + setRelayDiscoveryLoading(true);
293 + setRelayReleaseVersions({});
294 + setKnownRelays([]);
295 + setRelayDiscoveryMessage("");
296 +
297 + void (async () => {
298 + let discoveryMessage = "";
299 + let nextKnownRelays = normalizeKnownRelays(undefined, currentRelayURL);
300 +
301 + try {
302 + const discovery =
303 + await apiClient.get<RelayDiscoveryResponse>(API_PATHS.discovery);
304 + nextKnownRelays = normalizeKnownRelays(
305 + discovery?.relays,
306 + currentRelayURL
307 + );
308 + } catch {
309 + discoveryMessage = "Known relay data is unavailable on this relay.";
310 + }
311
239 - void loadRelayReleaseVersion(connectedRelayURL).then((releaseVersion) => {
240 - if (!cancelled) {
241 - setConnectedRelayReleaseVersion(releaseVersion);
312 + const relayURLs = nextKnownRelays.map((relay) => relay.relayURL);
313 + const uniqueRelayURLs = [...new Set(relayURLs)];
314 + const versions = await Promise.all(
315 + uniqueRelayURLs.map(async (relayURL) => [
316 + relayURL,
317 + await loadRelayReleaseVersion(relayURL),
318 + ] as const)
319 + );
320 +
321 + if (cancelled) {
322 + return;
323 }
243 - });
324 +
325 + setRelayReleaseVersions(Object.fromEntries(versions));
326 + setKnownRelays(nextKnownRelays);
327 + setRelayDiscoveryLoading(false);
328 + setRelayDiscoveryMessage(discoveryMessage);
329 + })();
330
331 return () => {
332 cancelled = true;
333 };
248 - }, [connectedRelayURL, isAdmin]);
334 + }, [currentRelayURL, isAdmin]);
335 const isAllSelected =
336 allIdentityKeys.length > 0 &&
337 allIdentityKeys.every((identityKey) => selectedIdentityKeys.has(identityKey));
@@ -749,44 +835,56 @@ export function ServerListView({
835 Public relays
836 </h2>
837 </div>
838 + <a
839 + href={OFFICIAL_REGISTRY_SOURCE_URL}
840 + target="_blank"
841 + rel="noopener noreferrer"
842 + className="inline-flex h-10 items-center justify-center rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/20"
843 + >
844 + Open registry.json
845 + </a>
846 </div>
847
754 - <div className="mt-6 flex flex-col gap-6">
755 - <div className="rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
756 - <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
757 - <p className="text-sm font-semibold uppercase tracking-[0.18em] text-text-muted">
758 - Connected relay
759 - </p>
760 - <a
761 - href={OFFICIAL_REGISTRY_SOURCE_URL}
762 - target="_blank"
763 - rel="noopener noreferrer"
764 - className="inline-flex h-10 items-center justify-center rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/20"
765 - >
766 - Open registry.json
767 - </a>
848 + <div className="mt-6 rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
849 + {relayDiscoveryLoading ? (
850 + <div className="rounded-2xl border border-border/70 bg-background/90 px-4 py-3 text-sm text-text-muted">
851 + Loading known relays...
852 </div>
769 - <div className="mt-5 flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
770 - <a
771 - href={connectedRelayURL}
772 - target="_blank"
773 - rel="noopener noreferrer"
774 - className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] text-foreground underline-offset-4 hover:underline sm:text-sm"
775 - >
776 - {connectedRelayURL}
777 - </a>
778 - <div className="flex shrink-0 flex-wrap items-center gap-2">
779 - <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
780 - Connected
781 - </span>
782 - {connectedRelayReleaseVersion ? (
783 - <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
784 - {connectedRelayReleaseVersion}
785 - </span>
786 - ) : null}
787 - </div>
853 + ) : knownRelays.length === 0 ? (
854 + <div className="rounded-2xl border border-border/70 bg-background/90 px-4 py-3 text-sm text-text-muted">
855 + No known relays discovered from this relay.
856 </div>
789 - </div>
857 + ) : (
858 + <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
859 + {knownRelays.map((relay) => (
860 + <div
861 + key={relay.relayURL}
862 + className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3"
863 + >
864 + <a
865 + href={relay.relayURL}
866 + target="_blank"
867 + rel="noopener noreferrer"
868 + className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] text-foreground underline-offset-4 hover:underline sm:text-sm"
869 + >
870 + {relay.relayURL}
871 + </a>
872 + {relayReleaseVersions[relay.relayURL] ? (
873 + <div className="flex shrink-0 items-center gap-2">
874 + <span className="rounded-full bg-background px-2.5 py-1 font-mono text-[11px] font-medium text-text-muted ring-1 ring-border">
875 + {relayReleaseVersions[relay.relayURL]}
876 + </span>
877 + </div>
878 + ) : null}
879 + </div>
880 + ))}
881 + </div>
882 + )}
883 + {relayDiscoveryMessage ? (
884 + <div className="mt-3 text-sm text-text-muted">
885 + {relayDiscoveryMessage}
886 + </div>
887 + ) : null}
888 </div>
889 </section>
890 </main>
frontend/src/lib/apiPaths.ts
+1
@@ -23,6 +23,7 @@ export const API_PATHS = {
23 tunnel: {
24 status: "/tunnel/status",
25 },
26 + discovery: "/discovery",
27 healthz: "/healthz",
28 install: {
29 shell: "/install.sh",
portal/discovery/refresher.go
+27 -7
@@ -184,24 +184,44 @@ func (r *Refresher) refreshOverlay(ctx context.Context) error {
184 if err := r.overlay.Sync(states); err != nil {
185 return err
186 }
187 + relaySetChanged := false
188 for _, state := range states {
189 relay := state.Descriptor
190 + recoveryFailures := r.directRecoveryFailures
191 + if state.Bootstrap {
192 + recoveryFailures = 0
193 + }
194 + startedAt := time.Now()
195 resp, err := r.overlay.DiscoverRelay(ctx, relay)
196 if err != nil {
191 - return err
197 + if ctx.Err() != nil {
198 + return ctx.Err()
199 + }
200 + if recoveryFailures > 0 {
201 + r.logDiscoveryFailure(relay.APIHTTPSAddr, relay.APIHTTPSAddr, recoveryFailures, err)
202 + }
203 + continue
204 }
205
194 - relaySetChanged, err := r.relaySet.ApplyRelayDiscoveryResponse(relay.APIHTTPSAddr, resp, time.Now().UTC())
206 + measuredAt := time.Now().UTC()
207 + changed, err := r.relaySet.ApplyRelayDiscoveryResponse(relay.APIHTTPSAddr, resp, measuredAt)
208 if err != nil {
196 - return err
197 - }
198 - if !relaySetChanged {
209 + if recoveryFailures > 0 {
210 + r.logDiscoveryFailure(relay.APIHTTPSAddr, relay.APIHTTPSAddr, recoveryFailures, err)
211 + }
212 continue
213 }
201 - if err := r.overlay.Sync(r.relaySet.OverlayPeerStates()); err != nil {
202 - return err
214 + r.relaySet.RecordDiscoveryRTT(relay.APIHTTPSAddr, time.Since(startedAt), measuredAt)
215 + if changed {
216 + relaySetChanged = true
217 }
218 }
219 + if !relaySetChanged {
220 + return nil
221 + }
222 + if err := r.overlay.Sync(r.relaySet.OverlayPeerStates()); err != nil {
223 + return err
224 + }
225 return nil
226 }
227