From d46738e9a6c0e4d385ef7e8315ef1fbd5b2c5be6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 15 May 2026 23:59:03 -0400 Subject: [PATCH] Improve nostr relay reliability --- src/index.html | 385 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 341 insertions(+), 44 deletions(-) diff --git a/src/index.html b/src/index.html index 4d322fd..731489f 100644 --- a/src/index.html +++ b/src/index.html @@ -1810,6 +1810,10 @@ +
Publishing requires a Nostr browser extension (Alby, nos2x).
@@ -2105,6 +2109,10 @@ +Disc designs publish as reusable single-disc designs. Publishing requires a Nostr browser extension.
@@ -2244,6 +2252,7 @@ + @@ -2468,8 +2477,12 @@ 'wss://relay.primal.net', 'wss://relay.snort.social', 'wss://relay.nostr.band', - 'wss://relay.nos.social' + 'wss://relay.nostr.pub' ]; + const RELAY_HEALTH_CACHE_KEY = 'casewrap-relay-health'; + const RELAY_FAILURE_COOLDOWN_MS = 10 * 60 * 1000; + const RELAY_FAILURE_MAX_COOLDOWN_MS = 60 * 60 * 1000; + const RELAY_HEALTH_MAX_AGE_MS = 24 * 60 * 60 * 1000; const state = { meta: { title: "", imdbId: "", omdbApiKey: "", category: "", language: "en" }, @@ -2532,6 +2545,8 @@ myPubkey: '', myProfile: null, myFollows: [], + relayHealth: loadRelayHealth(), + lastPublish: null, status: 'Refresh the feed when you want to browse Nostr templates.' } }; @@ -2557,6 +2572,12 @@ return slug || fallback; } + function shortEventId(id) { + const value = String(id || '').trim(); + if (!value) return ''; + return value.length <= 18 ? value : `${value.slice(0, 12)}…${value.slice(-8)}`; + } + function projectFileStem(kind, variant = '') { const title = filenamePart(state.meta.title, 'untitled'); const imdb = state.meta.imdbId @@ -2581,10 +2602,92 @@ function splitRelayList(raw) { return String(raw || '') .split(/[\s,]+/g) - .map((relay) => relay.trim()) + .map((relay) => relay.trim().replace(/\/+$/, '')) .filter(Boolean); } + function loadRelayHealth() { + try { + const raw = localStorage.getItem(RELAY_HEALTH_CACHE_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + } + + function saveRelayHealth(health) { + try { + const now = Date.now(); + const next = {}; + for (const [relay, entry] of Object.entries(health || {})) { + if (!relay || !entry) continue; + if (entry.lastSeenAt && now - entry.lastSeenAt > RELAY_HEALTH_MAX_AGE_MS) continue; + next[relay] = entry; + } + localStorage.setItem(RELAY_HEALTH_CACHE_KEY, JSON.stringify(next)); + } catch { + // Ignore storage failures. + } + } + + function normalizeRelayHealthRelay(relay) { + return String(relay || '').trim().replace(/\/+$/, ''); + } + + function getRelayHealth(relay) { + const key = normalizeRelayHealthRelay(relay); + if (!key) return null; + if (!state.community.relayHealth) state.community.relayHealth = loadRelayHealth(); + return state.community.relayHealth[key] || null; + } + + function relayIsCoolingDown(relay, now = Date.now()) { + const entry = getRelayHealth(relay); + return !!entry?.cooldownUntil && entry.cooldownUntil > now; + } + + function getActiveRelays(relays = splitRelayList(state.community.relays), { includeCoolingDown = false } = {}) { + const list = (relays.length ? relays : DEFAULT_NOSTR_RELAYS).map(normalizeRelayHealthRelay).filter(Boolean); + if (includeCoolingDown) return list; + const active = list.filter((relay) => !relayIsCoolingDown(relay)); + return active.length ? active : list; + } + + function markRelaySuccess(relay) { + const key = normalizeRelayHealthRelay(relay); + if (!key) return; + if (!state.community.relayHealth) state.community.relayHealth = loadRelayHealth(); + const current = state.community.relayHealth[key] || {}; + state.community.relayHealth[key] = { + ...current, + failCount: 0, + lastError: '', + lastSuccessAt: Date.now(), + lastSeenAt: Date.now(), + cooldownUntil: 0 + }; + saveRelayHealth(state.community.relayHealth); + } + + function markRelayFailure(relay, error = '') { + const key = normalizeRelayHealthRelay(relay); + if (!key) return; + if (!state.community.relayHealth) state.community.relayHealth = loadRelayHealth(); + const current = state.community.relayHealth[key] || {}; + const failCount = (current.failCount || 0) + 1; + const cooldownMs = Math.min(RELAY_FAILURE_MAX_COOLDOWN_MS, RELAY_FAILURE_COOLDOWN_MS * failCount); + state.community.relayHealth[key] = { + ...current, + failCount, + lastError: String(error || 'Relay failure').slice(0, 140), + lastFailureAt: Date.now(), + lastSeenAt: Date.now(), + cooldownUntil: Date.now() + cooldownMs + }; + saveRelayHealth(state.community.relayHealth); + } + function shortPubkey(pubkey) { if (!pubkey) return ''; return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`; @@ -2791,27 +2894,79 @@ } async function publishToRelays(event, relays = splitRelayList(state.community.relays)) { - const sockets = relays.map((relay) => { + const targets = getActiveRelays(relays, { includeCoolingDown: false }); + const results = await Promise.all(targets.map((relay) => new Promise((resolve) => { + let done = false; + let ws; + const finish = (result) => { + if (done) return; + done = true; + try { ws && ws.close(); } catch {} + resolve(result); + }; + try { - const ws = new WebSocket(relay); - ws.addEventListener('open', () => { - ws.send(JSON.stringify(['EVENT', event])); - ws.close(); - }); - return ws; - } catch { - return null; + ws = new WebSocket(relay); + } catch (error) { + markRelayFailure(relay, error?.message || 'Could not open websocket'); + resolve({ relay, ok: false, error: error?.message || 'Could not open websocket' }); + return; } - }).filter(Boolean); - await Promise.allSettled(sockets.map((ws) => new Promise((resolve) => { - ws.addEventListener('close', resolve, { once: true }); - ws.addEventListener('error', resolve, { once: true }); - setTimeout(resolve, 1200); + + const timer = setTimeout(() => finish({ relay, ok: false, error: 'Publish timed out' }), 3500); + + ws.addEventListener('open', () => { + try { + ws.send(JSON.stringify(['EVENT', event])); + } catch (error) { + clearTimeout(timer); + markRelayFailure(relay, error?.message || 'Could not send event'); + finish({ relay, ok: false, error: error?.message || 'Could not send event' }); + } + }); + + ws.addEventListener('message', (msg) => { + let data; + try { + data = JSON.parse(msg.data); + } catch { + return; + } + if (data[0] === 'OK') { + clearTimeout(timer); + if (data[2]) markRelaySuccess(relay); else markRelayFailure(relay, data[3] || 'Relay rejected event'); + finish({ relay, ok: !!data[2], message: data[3] || '' }); + } + if (data[0] === 'NOTICE') { + clearTimeout(timer); + markRelayFailure(relay, data[1] || 'Relay notice'); + finish({ relay, ok: false, error: data[1] || 'Relay notice' }); + } + }); + + ws.addEventListener('error', () => { + if (done) return; + clearTimeout(timer); + markRelayFailure(relay, 'Websocket error'); + finish({ relay, ok: false, error: 'Websocket error' }); + }); + ws.addEventListener('close', () => { + if (done) return; + clearTimeout(timer); + markRelayFailure(relay, 'Relay closed the connection'); + finish({ relay, ok: false, error: 'Relay closed the connection' }); + }); }))); + const accepted = results.filter((result) => result && result.ok); + if (!accepted.length) { + const detail = results.find((result) => result && result.error)?.error || 'No relay accepted the event.'; + throw new Error(`Publish failed: ${detail}`); + } + return { accepted, results }; } async function queryRelays(relays, filters, { timeoutMs = 3500 } = {}) { - const relayList = relays.length ? relays : DEFAULT_NOSTR_RELAYS; + const relayList = getActiveRelays(relays, { includeCoolingDown: false }); const events = new Map(); await Promise.all(relayList.map((relay) => new Promise((resolve) => { @@ -2827,6 +2982,7 @@ try { ws = new WebSocket(relay); } catch { + markRelayFailure(relay, 'Could not open websocket'); resolve(); return; } @@ -2838,6 +2994,7 @@ ws.send(JSON.stringify(['REQ', subId, filters])); } catch { clearTimeout(timer); + markRelayFailure(relay, 'Could not send query'); finish(); } }); @@ -2850,14 +3007,24 @@ } if (data[0] === 'EVENT' && data[1] === subId && data[2]?.id) { events.set(data[2].id, data[2]); + markRelaySuccess(relay); } if (data[0] === 'EOSE' && data[1] === subId) { clearTimeout(timer); + markRelaySuccess(relay); finish(); } }); ws.addEventListener('error', () => { + if (done) return; clearTimeout(timer); + markRelayFailure(relay, 'Websocket error'); + finish(); + }); + ws.addEventListener('close', () => { + if (done) return; + clearTimeout(timer); + markRelayFailure(relay, 'Relay closed the connection'); finish(); }); }))); @@ -2865,6 +3032,20 @@ return [...events.values()].sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); } + async function verifyEventOnRelays(event, relays = splitRelayList(state.community.relays), attempts = 3) { + if (!event?.id) return { verified: false, attempts: 0, matches: [] }; + for (let attempt = 1; attempt <= attempts; attempt++) { + const matches = await queryRelays(getActiveRelays(relays), { ids: [event.id], limit: 1 }, { timeoutMs: 2500 + (attempt * 500) }); + if (matches.some((item) => item.id === event.id)) { + return { verified: true, attempts: attempt, matches }; + } + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, attempt * 1000)); + } + } + return { verified: false, attempts, matches: [] }; + } + function eventAuthorProfile(event) { if (!event?.pubkey) return null; return state.community.profiles?.[event.pubkey] || null; @@ -3000,8 +3181,8 @@ return topLanguage || bottomLanguage || 'en'; } - function syncProjectMetaFromDiscMeta() { - if (state.templateMode !== 'disc') return; + function syncProjectMetaFromDiscMeta(force = false) { + if (!force && state.templateMode !== 'disc') return; const nextTitle = preferredDiscProjectTitle(); const nextImdb = preferredDiscProjectImdb(); const nextCategory = preferredDiscProjectCategory(); @@ -3106,13 +3287,14 @@ const dl = $('designerLanguageField'); if (dl) dl.value = state.meta?.language || 'en'; const metadataFieldset = $('metadataFieldset'); if (metadataFieldset) metadataFieldset.style.display = state.templateMode === 'disc' ? 'none' : ''; - if (state.templateMode === 'disc') syncProjectMetaFromDiscMeta(); + if (state.templateMode === 'disc' && state.appMode !== 'designer') syncProjectMetaFromDiscMeta(true); $('showBleed').checked = state.showBleed; $('showCrops').checked = state.showCrops; $('printGuides').checked = state.printGuides; $('printCrops').checked = state.printCrops; const stt = $('spineTextTop'); if (stt) stt.value = state.discSpineLabels?.top || ''; const stb = $('spineTextBottom'); if (stb) stb.value = state.discSpineLabels?.bottom || ''; + syncPublishReadout(); syncModeTabsUI(); } @@ -3730,6 +3912,7 @@ state.discs[key].layers = layersToUse.map(l => ({ ...l })); } if (state.templateMode !== 'disc') state.templateMode = 'disc'; + syncProjectMetaFromDiscMeta(true); syncInputs(); syncAllDiscInputs(); render(); @@ -3738,7 +3921,7 @@ } else if (parsed?.discTemplate?.discs?.[key]) { state.discs[key] = parsed.discTemplate.discs[key]; state.discMeta[key] = { ...state.discMeta[key], ...discSlotMetaFromPayload(parsed, key, parsed.discTemplate.discs[key]) }; - syncProjectMetaFromDiscMeta(); + syncProjectMetaFromDiscMeta(true); syncAllDiscInputs(); render(); const preview = $(`disc-preview-${key}`); @@ -4523,7 +4706,6 @@ const relays = splitRelayList(state.community.relays); const kinds = communityKindKinds(); const filter = { kinds, limit: 120 }; - if (kinds.includes(30078)) filter['#t'] = ['casewrap']; const items = await queryRelays(relays, filter, { timeoutMs: 4000 }); const q = state.community.query.trim().toLowerCase(); const filtered = items.filter((event) => { @@ -4711,8 +4893,7 @@ ]; const evt = { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags, content: JSON.stringify(payload) }; const signed = await publishCommunityEvent(evt); - cachePublishedTemplateEvent(signed); - state.community.status = `Published "${state.meta.title}" to Nostr.`; + await finalizePublishedTemplateEvent(signed, { label: `Template "${state.meta.title}"` }); return signed; } @@ -4740,13 +4921,13 @@ content: JSON.stringify(payload) }; const signed = await publishCommunityEvent(evt); - cachePublishedTemplateEvent(signed); - state.community.status = `Published ${designName} to Nostr.`; + await finalizePublishedTemplateEvent(signed, { label: `Disc design "${designName}"` }); return signed; } function cachePublishedTemplateEvent(event) { if (!event || (event.kind !== 30078 && event.kind !== 5)) return; + persistPublishedTemplateEvent(event); if (!_feedCache.events) _feedCache.events = []; _feedCache.events = [event, ..._feedCache.events.filter(e => e.id !== event.id)]; _feedCache.fetchedAt = Date.now(); @@ -4754,14 +4935,41 @@ } function mergeCachedPublishedEvents(events) { - const merged = new Map(); - for (const event of events || []) { - if (event?.id) merged.set(event.id, event); - } - for (const event of _feedCache.events || []) { - if (event?.id) merged.set(event.id, event); - } - return [...merged.values()]; + return mergePublishedTemplateEvents([...(events || []), ...(_feedCache.events || [])]); + } + + function syncPublishReadout() { + const last = state.community.lastPublish; + const base = last + ? `Event ID: ${last.id}${last.verified ? ' • verified on relays' : ' • not yet verified on relays'}` + : 'No template has been published yet.'; + const detail = last + ? `Kind ${last.kind || 30078}${last.label ? ` • ${last.label}` : ''}${last.attempts ? ` • checked ${last.attempts} time${last.attempts === 1 ? '' : 's'}` : ''}` + : ''; + const text = detail ? `${base}\n${detail}` : base; + const templateReadout = $('templatePublishReadout'); + if (templateReadout) templateReadout.textContent = text; + const designerReadout = $('designerPublishReadout'); + if (designerReadout) designerReadout.textContent = last?.label === 'Disc design' ? text : (last ? text : 'No disc design has been published yet.'); + } + + async function finalizePublishedTemplateEvent(signed, { label = 'Template' } = {}) { + cachePublishedTemplateEvent(signed); + const verification = await verifyEventOnRelays(signed); + state.community.lastPublish = { + id: signed.id, + kind: signed.kind, + label, + verified: verification.verified, + attempts: verification.attempts, + created_at: signed.created_at, + relays: splitRelayList(state.community.relays) + }; + state.community.status = verification.verified + ? `${label} published. Event ${shortEventId(signed.id)} verified on relays.` + : `${label} published. Event ${shortEventId(signed.id)} not yet verified on relays.`; + syncPublishReadout(); + return { signed, verification }; } async function publishCommunityNote(text, replyToEvent = null) { @@ -5291,7 +5499,7 @@ selectedLayerId: '' }); normalizeDiscState(); - syncProjectMetaFromDiscMeta(); + syncProjectMetaFromDiscMeta(true); syncInputs(); syncAllDiscInputs(); render(); return; } @@ -5362,7 +5570,7 @@ Object.assign(state, next); normalizeDiscState(); normalizeJewelState(); - syncProjectMetaFromDiscMeta(); + syncProjectMetaFromDiscMeta(true); syncInputs(); syncAllSlotInputs(); syncAllDiscInputs(); @@ -5373,6 +5581,44 @@ const _feedCache = { events: null, reactions: {}, profiles: {}, fetchedAt: 0, loading: false, relayKey: '' }; + let _nostrBrowseTab = 'New'; + const PUBLISHED_TEMPLATE_CACHE_KEY = 'casewrap-published-template-events'; + + function loadPersistedPublishedTemplateEvents() { + try { + const raw = localStorage.getItem(PUBLISHED_TEMPLATE_CACHE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed.filter((event) => event && event.id) : []; + } catch { + return []; + } + } + + function savePersistedPublishedTemplateEvents(events) { + try { + localStorage.setItem(PUBLISHED_TEMPLATE_CACHE_KEY, JSON.stringify(events.slice(0, 150))); + } catch { + // Ignore storage failures. + } + } + + function persistPublishedTemplateEvent(event) { + if (!event || (event.kind !== 30078 && event.kind !== 5) || !event.id) return; + const existing = loadPersistedPublishedTemplateEvents().filter((item) => item.id !== event.id); + existing.unshift(event); + savePersistedPublishedTemplateEvents(existing); + } + + function mergePublishedTemplateEvents(events = []) { + const merged = new Map(); + for (const event of events || []) { + if (event?.id) merged.set(event.id, event); + } + for (const event of loadPersistedPublishedTemplateEvents()) { + if (event?.id) merged.set(event.id, event); + } + return [...merged.values()]; + } function invalidateFeedCache() { _feedCache.events = null; @@ -5391,10 +5637,10 @@ _feedCache.relayKey = relayKey; try { const [templateEvents, deletionEvents] = await Promise.all([ - queryRelays(relays, { kinds: [30078], '#t': ['casewrap'], limit: 200 }, { timeoutMs: 4500 }), - queryRelays(relays, { kinds: [5], '#t': ['casewrap'], limit: 100 }, { timeoutMs: 3500 }) + queryRelays(relays, { kinds: [30078], limit: 200 }, { timeoutMs: 4500 }), + queryRelays(relays, { kinds: [5], limit: 100 }, { timeoutMs: 3500 }) ]); - const events = [...templateEvents, ...deletionEvents]; + const events = mergePublishedTemplateEvents([...templateEvents, ...deletionEvents]); _feedCache.events = events; _feedCache.reactions = {}; _feedCache.profiles = {}; @@ -5610,7 +5856,7 @@ wall.innerHTML = '