interactive intro to open social at-me.zzstoatzz.io

fix: improve domain validation for filter functionality

- Make renderVisualization async and await validateAppUrls to ensure
validation completes before filter panel initializes
- Rewrite validateAppUrls with two-phase approach:
1. Check if domain resolves as ATProto handle (fast, no CORS issues)
2. Fall back to HEAD request for non-ATProto domains
- Mark domains as invalid on any fetch error (not just Chrome-specific
error strings) for cross-browser compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+32 -23
+27 -18
src/view/atproto.js
··· 152 152 const url = link?.dataset.url; 153 153 if (!url || !link) return; 154 154 155 + let parsedUrl; 155 156 try { 156 - new URL(url); // Check syntax first 157 + parsedUrl = new URL(url); 157 158 } catch (e) { 158 159 markInvalid(link, namespace, 'malformed URL'); 159 160 return; 160 161 } 161 162 162 - // Try HEAD request with short timeout to check if domain is reachable 163 + // Use a two-phase approach: 164 + // 1. Try to resolve the domain as an ATProto handle (fast, no CORS issues) 165 + // 2. If that fails, try a HEAD request to check if domain exists 166 + 167 + const domain = parsedUrl.hostname; 168 + 169 + // Phase 1: Check if domain resolves as ATProto handle 163 170 try { 164 - const controller = new AbortController(); 165 - const timeout = setTimeout(() => controller.abort(), 3000); 171 + const response = await fetch( 172 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(domain)}`, 173 + { signal: AbortSignal.timeout(3000) } 174 + ); 175 + if (response.ok) { 176 + // Domain exists as ATProto handle - definitely valid 177 + return; 178 + } 179 + } catch (e) { 180 + // Continue to phase 2 181 + } 166 182 183 + // Phase 2: Try HEAD request with no-cors to check domain reachability 184 + try { 167 185 await fetch(url, { 168 186 method: 'HEAD', 169 187 mode: 'no-cors', 170 - signal: controller.signal, 188 + signal: AbortSignal.timeout(3000), 171 189 }); 172 - 173 - clearTimeout(timeout); 174 - // If we get here, domain is reachable (even if response is opaque due to CORS) 190 + // If we get here without throwing, domain is reachable 175 191 } catch (e) { 176 - // Only mark as invalid for actual DNS/connection failures 177 - // CORS blocks mean the server IS reachable, just not allowing our request 178 - const errorMsg = e.message || ''; 179 - if (errorMsg.includes('ERR_NAME_NOT_RESOLVED') || 180 - errorMsg.includes('ERR_CONNECTION_REFUSED') || 181 - errorMsg.includes('ERR_CONNECTION_TIMED_OUT') || 182 - e.name === 'AbortError') { 183 - markInvalid(link, namespace, 'domain not reachable'); 184 - } 185 - // For CORS blocks (ERR_FAILED) and other errors, server exists so don't mark invalid 192 + // Any fetch error means domain is not reachable 193 + // (TypeError for network errors, AbortError for timeout) 194 + markInvalid(link, namespace, 'domain not reachable'); 186 195 } 187 196 }); 188 197
+2 -2
src/view/main.js
··· 129 129 // Hide status 130 130 statusEl.style.display = 'none'; 131 131 132 - // Render visualization 133 - renderVisualization(apps, profile); 132 + // Render visualization (await to ensure URL validation completes) 133 + await renderVisualization(apps, profile); 134 134 135 135 // Initialize UI components 136 136 initGuestbookUI();
+3 -3
src/view/visualization.js
··· 13 13 import { initFilterPanel, repositionAppCircles } from './filters.js'; 14 14 import { loadMSTStructure } from './mst.js'; 15 15 16 - export function renderVisualization(apps, profile) { 16 + export async function renderVisualization(apps, profile) { 17 17 const field = document.getElementById('field'); 18 18 field.innerHTML = ''; 19 19 field.classList.remove('loading'); ··· 109 109 }); 110 110 }); 111 111 112 - // Validate app URLs (client-side check via image load) 113 - validateAppUrls(appDivs); 112 + // Validate app URLs (must complete before filter panel setup) 113 + await validateAppUrls(appDivs); 114 114 115 115 // Set up identity click handler 116 116 setupIdentityClickHandler(allCollections, appCount, profile);