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

fix: validate app domains with HEAD requests

Use no-cors HEAD requests to check if app domains are actually
reachable. Only mark as invalid for DNS/connection failures
(ERR_NAME_NOT_RESOLVED, ERR_CONNECTION_REFUSED, timeout).
CORS blocks indicate the server exists, so don't mark invalid.

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

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

Changed files
+44 -16
src
view
+44 -16
src/view/atproto.js
··· 142 142 return avatars; 143 143 } 144 144 145 - // Validate app URLs - only mark as invalid if URL is malformed 146 - // Namespaces come from ATProto lexicons, so reversed domains are valid by definition 145 + // Validate app URLs by checking if the domain is reachable 147 146 export async function validateAppUrls(appDivs) { 148 147 // Clear previous invalid apps 149 148 state.invalidApps.clear(); 150 149 151 - appDivs.forEach(({ div, namespace }) => { 150 + const validationPromises = appDivs.map(async ({ div, namespace }) => { 152 151 const link = div.querySelector('.app-name'); 153 152 const url = link?.dataset.url; 154 - if (!url) return; 153 + if (!url || !link) return; 155 154 156 155 try { 157 - // Just check if it's a valid URL - don't try to fetch or validate the domain 158 - new URL(url); 156 + new URL(url); // Check syntax first 159 157 } catch (e) { 160 - // Malformed URL - mark as invalid 161 - if (link) { 162 - const displayName = link.textContent.replace(' ↗', '').replace(' \u2197', ''); 163 - link.classList.add('invalid-link'); 164 - link.setAttribute('title', 'malformed URL'); 165 - link.style.pointerEvents = 'none'; 166 - link.textContent = displayName; 167 - } 168 - if (namespace) { 169 - state.invalidApps.add(namespace); 158 + markInvalid(link, namespace, 'malformed URL'); 159 + return; 160 + } 161 + 162 + // Try HEAD request with short timeout to check if domain is reachable 163 + try { 164 + const controller = new AbortController(); 165 + const timeout = setTimeout(() => controller.abort(), 3000); 166 + 167 + await fetch(url, { 168 + method: 'HEAD', 169 + mode: 'no-cors', 170 + signal: controller.signal, 171 + }); 172 + 173 + clearTimeout(timeout); 174 + // If we get here, domain is reachable (even if response is opaque due to CORS) 175 + } 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'); 170 184 } 185 + // For CORS blocks (ERR_FAILED) and other errors, server exists so don't mark invalid 171 186 } 172 187 }); 188 + 189 + await Promise.all(validationPromises); 190 + } 191 + 192 + function markInvalid(link, namespace, reason) { 193 + const displayName = link.textContent.replace(' ↗', '').replace(' \u2197', ''); 194 + link.classList.add('invalid-link'); 195 + link.setAttribute('title', reason); 196 + link.style.pointerEvents = 'none'; 197 + link.textContent = displayName; 198 + if (namespace) { 199 + state.invalidApps.add(namespace); 200 + } 173 201 }