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