1470 lines
55 KiB
HTML
1470 lines
55 KiB
HTML
<!DOCTYPE html>
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html lang="en" dir="ltr">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>KEEP ALIVE</title>
|
|
|
|
<link rel="stylesheet" href="./css/client.css" />
|
|
|
|
<script>
|
|
(function () {
|
|
const savedTheme = localStorage.getItem('theme');
|
|
if (savedTheme === 'dark') {
|
|
document.documentElement.classList.add('dark-mode');
|
|
if (document.body) document.body.classList.add('dark-mode');
|
|
}
|
|
})();
|
|
</script>
|
|
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
|
<script src="./js/vendor/svg.min.js"></script>
|
|
|
|
<style>
|
|
#divBody {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
padding: 14px 14px 140px 14px;
|
|
overflow-y: auto;
|
|
scrollbar-gutter: stable;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
height: auto;
|
|
min-height: calc(100% - 120px);
|
|
}
|
|
|
|
.kaCard {
|
|
border: 1px solid var(--muted-color);
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
background: var(--secondary-color);
|
|
width: 100%;
|
|
min-width: 0;
|
|
box-sizing: border-box;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
#kaControls {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.kaBtn {
|
|
border: 1px solid var(--muted-color);
|
|
color: var(--primary-color);
|
|
background: var(--background-color);
|
|
border-radius: 8px;
|
|
padding: 7px 10px;
|
|
font-family: var(--font-family);
|
|
cursor: pointer;
|
|
min-width: 148px;
|
|
text-align: left;
|
|
}
|
|
|
|
.kaBtn:hover { border-color: var(--accent-color); }
|
|
.kaBtn:disabled {
|
|
opacity: 0.55;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.kaStatus {
|
|
display: inline-block;
|
|
width: 18px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
#kaStats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 8px;
|
|
font-size: 90%;
|
|
}
|
|
|
|
.kaStatRow {
|
|
border: 1px dashed var(--muted-color);
|
|
border-radius: 6px;
|
|
padding: 8px;
|
|
background: var(--background-color);
|
|
}
|
|
|
|
#kaKindsTableWrap {
|
|
max-height: 180px;
|
|
overflow: auto;
|
|
border: 1px solid var(--muted-color);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
#kaKindsTable {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 85%;
|
|
}
|
|
|
|
#kaKindsTable th,
|
|
#kaKindsTable td {
|
|
border-bottom: 1px solid var(--muted-color);
|
|
padding: 4px 6px;
|
|
text-align: left;
|
|
}
|
|
|
|
#kaLog {
|
|
border: 1px solid var(--muted-color);
|
|
border-radius: 8px;
|
|
background: var(--background-color);
|
|
color: var(--primary-color);
|
|
min-height: 240px;
|
|
max-height: 340px;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
box-sizing: border-box;
|
|
overflow-y: auto;
|
|
overflow-x: auto;
|
|
scrollbar-gutter: stable;
|
|
padding: 8px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
white-space: pre-wrap;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
#kaRelayJson {
|
|
border: 1px solid var(--muted-color);
|
|
border-radius: 8px;
|
|
background: var(--background-color);
|
|
color: var(--primary-color);
|
|
min-height: 180px;
|
|
max-height: 420px;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
box-sizing: border-box;
|
|
overflow-y: auto;
|
|
overflow-x: auto;
|
|
scrollbar-gutter: stable;
|
|
padding: 8px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
white-space: pre;
|
|
line-height: 1.35;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div id="divSvgHam" class="divHeaderButtons"></div>
|
|
|
|
<div id="divHeader">
|
|
<div id="divHeaderFlexLeft"></div>
|
|
<div id="divHeaderFlexCenter">
|
|
<div class="divHeaderText">KEEP ALIVE</div>
|
|
</div>
|
|
<div id="divHeaderFlexRight"></div>
|
|
</div>
|
|
|
|
<div id="divBody">
|
|
<div class="kaCard" id="kaControls">
|
|
<button class="kaBtn" id="btnHalt"><span class="kaStatus" id="stHalt">■</span>HALT Current Process</button>
|
|
<button class="kaBtn" id="btnLoadRegistry"><span class="kaStatus" id="stLoadRegistry">○</span>Load Saved Relay Lists</button>
|
|
<button class="kaBtn" id="btnFindEvents"><span class="kaStatus" id="stFindEvents">○</span>Find My Events</button>
|
|
<button class="kaBtn" id="btnDiagnose"><span class="kaStatus" id="stDiagnose">○</span>Diagnose 7375/7376</button>
|
|
<button class="kaBtn" id="btnFindRelays" disabled><span class="kaStatus" id="stFindRelays">○</span>Find Relays</button>
|
|
<button class="kaBtn" id="btnTestRelays" disabled><span class="kaStatus" id="stTestRelays">○</span>Test Relays (kind 0)</button>
|
|
<button class="kaBtn" id="btnSaveRegistry" disabled><span class="kaStatus" id="stSaveRegistry">○</span>Save Relay Registry</button>
|
|
<button class="kaBtn" id="btnSpreadCritical" disabled><span class="kaStatus" id="stSpreadCritical">○</span>Spread Critical</button>
|
|
<button class="kaBtn" id="btnSpreadHigh" disabled><span class="kaStatus" id="stSpreadHigh">○</span>Spread High</button>
|
|
<button class="kaBtn" id="btnSpreadMedium" disabled><span class="kaStatus" id="stSpreadMedium">○</span>Spread Medium</button>
|
|
<button class="kaBtn" id="btnSpreadLow" disabled><span class="kaStatus" id="stSpreadLow">○</span>Spread Low</button>
|
|
</div>
|
|
|
|
<div class="kaCard" id="kaStats">
|
|
<div class="kaStatRow" id="statEvents">Events: 0</div>
|
|
<div class="kaStatRow" id="statRelays">Discovered relays: 0</div>
|
|
<div class="kaStatRow" id="statAvailable">Available relays: 0</div>
|
|
<div class="kaStatRow" id="statSpread">Spread attempts: 0</div>
|
|
</div>
|
|
|
|
<div class="kaCard">
|
|
<div style="margin-bottom:6px; font-weight:bold;">Kinds summary</div>
|
|
<div id="kaKindsTableWrap">
|
|
<table id="kaKindsTable">
|
|
<thead><tr><th>Kind</th><th>Count</th><th>Priority</th></tr></thead>
|
|
<tbody id="kaKindsBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="kaCard">
|
|
<div style="margin-bottom:6px; font-weight:bold;">Activity log</div>
|
|
<div id="kaLog"></div>
|
|
</div>
|
|
|
|
<div class="kaCard">
|
|
<div style="margin-bottom:6px; font-weight:bold;">Relay registry JSON preview (to be saved as kind 30078 #t:relay-list)</div>
|
|
<pre id="kaRelayJson">{}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="divFooter">
|
|
<div id="divFooterLeft" class="divFooterBox"></div>
|
|
<div id="divFooterCenter" class="divFooterBox"></div>
|
|
<div id="divFooterRight" class="divFooterBox"></div>
|
|
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
|
</div>
|
|
|
|
<div id="divSideNav">
|
|
<div id="divSideNavHeader"></div>
|
|
<div id="divSideNavBody"><div id="divFiles"></div></div>
|
|
|
|
<div id="divAiSection" class="sidenavSection">
|
|
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
|
<div id="divAiList" class="sidenavSectionList"><div id="divAiProvidersList">No saved providers yet.</div></div>
|
|
</div>
|
|
|
|
<div id="divRelaySection">
|
|
<div id="divRelaySectionTitle">リレー</div>
|
|
<div id="divRelayList">Loading relays...</div>
|
|
</div>
|
|
|
|
<div id="divBlossomSection">
|
|
<div id="divBlossomSectionTitle">ブロッサム</div>
|
|
<div id="divBlossomList">Loading blossom servers...</div>
|
|
</div>
|
|
|
|
<div id="divVersionBar">
|
|
<span id="versionDisplay">v0.0.1</span>
|
|
<div id="divVersionBarButtons">
|
|
<button id="themeToggleButton" title="Toggle Dark/Light Mode"><div id="themeToggleHamburgerContainer"></div></button>
|
|
<button id="logoutButton" title="Logout"><div id="logoutHamburgerContainer"></div></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="./nostr.bundle.js"></script>
|
|
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
|
|
|
<script type="module">
|
|
import {
|
|
initNDKPage,
|
|
getPubkey,
|
|
injectHeaderAvatar,
|
|
disconnect,
|
|
getVersion,
|
|
updateVersionDisplay,
|
|
ndkFetchEvents,
|
|
fetchEvents,
|
|
publishEvent,
|
|
publishRawEvent,
|
|
getRelayData
|
|
} from './js/init-ndk.mjs';
|
|
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
|
|
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection } from './js/relay-ui.mjs';
|
|
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
|
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
|
|
|
const RELAY_LIST_TAG = 'relay-list';
|
|
const RELAY_LIST_D_PREFIX = 'relay-list-';
|
|
const CHUNK_SIZE = 100;
|
|
const RELAY_TEST_TIMEOUT_MS = 7000;
|
|
const SPREAD_DELAY_MS = 450;
|
|
const FIND_RELAYS_TEST_LIMIT = 100;
|
|
|
|
const PRIORITY_CRITICAL = new Set([0, 10002, 7375, 7376, 17375, 37375]);
|
|
const PRIORITY_HIGH = new Set([3, 10000, 30078, 30023]);
|
|
const PRIORITY_MEDIUM = new Set([30311, 30024, 1063, 9735]);
|
|
|
|
const state = {
|
|
updateIntervalId: null,
|
|
pubkey: '',
|
|
allEvents: [],
|
|
kind0Event: null,
|
|
eventsByKind: new Map(),
|
|
eventsByPriority: {
|
|
critical: [],
|
|
high: [],
|
|
medium: [],
|
|
low: []
|
|
},
|
|
discoveredRelays: new Set(),
|
|
relayMetadata: new Map(),
|
|
availableRelays: [],
|
|
spreadStats: {
|
|
attempts: 0,
|
|
success: 0,
|
|
failed: 0
|
|
},
|
|
haltRequested: false
|
|
};
|
|
|
|
let hamburgerInstance = null;
|
|
let isNavOpen = false;
|
|
let logoutHamburger = null;
|
|
let themeToggleHamburger = null;
|
|
let isDarkMode = false;
|
|
|
|
const divBody = document.getElementById('divBody');
|
|
const divSideNav = document.getElementById('divSideNav');
|
|
const divFooterCenter = document.getElementById('divFooterCenter');
|
|
const divFooterRight = document.getElementById('divFooterRight');
|
|
const logEl = document.getElementById('kaLog');
|
|
const kindsBody = document.getElementById('kaKindsBody');
|
|
const relayJsonEl = document.getElementById('kaRelayJson');
|
|
|
|
const stepConfig = {
|
|
loadRegistry: { status: 'stLoadRegistry', button: 'btnLoadRegistry' },
|
|
findEvents: { status: 'stFindEvents', button: 'btnFindEvents' },
|
|
diagnose: { status: 'stDiagnose', button: 'btnDiagnose' },
|
|
findRelays: { status: 'stFindRelays', button: 'btnFindRelays' },
|
|
testRelays: { status: 'stTestRelays', button: 'btnTestRelays' },
|
|
saveRegistry: { status: 'stSaveRegistry', button: 'btnSaveRegistry' },
|
|
spreadCritical: { status: 'stSpreadCritical', button: 'btnSpreadCritical' },
|
|
spreadHigh: { status: 'stSpreadHigh', button: 'btnSpreadHigh' },
|
|
spreadMedium: { status: 'stSpreadMedium', button: 'btnSpreadMedium' },
|
|
spreadLow: { status: 'stSpreadLow', button: 'btnSpreadLow' }
|
|
};
|
|
|
|
function logLine(text, level = 'info') {
|
|
const ts = new Date().toISOString().slice(11, 19);
|
|
const marker = level === 'error' ? '✕' : level === 'ok' ? '✓' : level === 'warn' ? '⚠' : '•';
|
|
logEl.textContent += `[${ts}] ${marker} ${text}\n`;
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
|
|
function requestHalt() {
|
|
state.haltRequested = true;
|
|
logLine('HALT requested. Current process will stop at next safe checkpoint.', 'warn');
|
|
}
|
|
|
|
function resetHalt() {
|
|
state.haltRequested = false;
|
|
}
|
|
|
|
function throwIfHalted() {
|
|
if (state.haltRequested) {
|
|
throw new Error('HALTED_BY_USER');
|
|
}
|
|
}
|
|
|
|
function isReplaceableKind(kind) {
|
|
const n = Number(kind || 0);
|
|
return n === 0 || n === 3 || (n >= 10000 && n < 20000) || (n >= 30000 && n < 40000);
|
|
}
|
|
|
|
function extractRawEventCandidate(event) {
|
|
if (!event || typeof event !== 'object') return event || {};
|
|
|
|
try {
|
|
if (typeof event.rawEvent === 'function') {
|
|
const raw = event.rawEvent();
|
|
if (raw && typeof raw === 'object') return raw;
|
|
}
|
|
} catch {}
|
|
|
|
try {
|
|
if (typeof event.toNostrEvent === 'function') {
|
|
const raw = event.toNostrEvent();
|
|
if (raw && typeof raw === 'object') return raw;
|
|
}
|
|
} catch {}
|
|
|
|
if (event.rawEvent && typeof event.rawEvent === 'object') return event.rawEvent;
|
|
if (event.event && typeof event.event === 'object') return event.event;
|
|
if (event._event && typeof event._event === 'object') return event._event;
|
|
return event;
|
|
}
|
|
|
|
function canonicalizeNostrEvent(event) {
|
|
const source = extractRawEventCandidate(event);
|
|
const canonical = {
|
|
id: String(source?.id || ''),
|
|
pubkey: String(source?.pubkey || ''),
|
|
created_at: Number(source?.created_at || 0),
|
|
kind: Number(source?.kind || event?.kind || 0),
|
|
tags: Array.isArray(source?.tags)
|
|
? source.tags.map((t) => Array.isArray(t) ? t.map((v) => String(v)) : [String(t)])
|
|
: [],
|
|
content: String(source?.content || ''),
|
|
sig: String(source?.sig || '')
|
|
};
|
|
return canonical;
|
|
}
|
|
|
|
function validateEventSignatureLocally(event) {
|
|
const canonical = canonicalizeNostrEvent(event);
|
|
if (!canonical.id || !canonical.sig || !canonical.pubkey || !canonical.created_at) {
|
|
return { ok: false, reason: 'missing-id-sig-or-core-fields' };
|
|
}
|
|
|
|
const verifyFn = window?.NostrTools?.verifyEvent;
|
|
const hashFn = window?.NostrTools?.getEventHash;
|
|
if (typeof verifyFn !== 'function' || typeof hashFn !== 'function') {
|
|
return { ok: true, reason: 'skipped-local-verify-missing-nostrtools' };
|
|
}
|
|
|
|
try {
|
|
const hashable = {
|
|
pubkey: canonical.pubkey,
|
|
created_at: canonical.created_at,
|
|
kind: canonical.kind,
|
|
tags: canonical.tags,
|
|
content: canonical.content
|
|
};
|
|
const computedId = String(hashFn(hashable) || '');
|
|
if (!computedId || computedId !== canonical.id) {
|
|
return { ok: false, reason: 'id-hash-mismatch' };
|
|
}
|
|
const ok = Boolean(verifyFn(canonical));
|
|
return ok ? { ok: true, reason: 'verified' } : { ok: false, reason: 'bad-signature' };
|
|
} catch (error) {
|
|
return { ok: false, reason: `verify-error:${String(error?.message || error)}` };
|
|
}
|
|
}
|
|
|
|
async function maybeResignEvent(event) {
|
|
if (!event || !isReplaceableKind(event.kind)) return canonicalizeNostrEvent(event || {});
|
|
if (!window.nostr || typeof window.nostr.signEvent !== 'function') {
|
|
throw new Error('window.nostr.signEvent unavailable for re-signing replaceable events');
|
|
}
|
|
|
|
const draft = {
|
|
kind: Number(event.kind),
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: Array.isArray(event.tags) ? event.tags.map((t) => Array.isArray(t) ? [...t] : t) : [],
|
|
content: String(event.content || '')
|
|
};
|
|
|
|
const signed = await window.nostr.signEvent(draft);
|
|
return canonicalizeNostrEvent(signed);
|
|
}
|
|
|
|
async function prepareEventsForRelayPublishing(events) {
|
|
const prepared = [];
|
|
let skippedInvalid = 0;
|
|
for (const ev of events || []) {
|
|
throwIfHalted();
|
|
if (isReplaceableKind(ev.kind)) {
|
|
const signed = await maybeResignEvent(ev);
|
|
prepared.push(canonicalizeNostrEvent(signed));
|
|
} else {
|
|
const canonical = canonicalizeNostrEvent(ev);
|
|
const verify = validateEventSignatureLocally(canonical);
|
|
if (!verify.ok) {
|
|
skippedInvalid++;
|
|
logLine(`Skipping invalid event kind ${canonical.kind} (${String(canonical.id || '').slice(0, 8)}...): ${verify.reason}`, 'warn');
|
|
continue;
|
|
}
|
|
prepared.push(canonical);
|
|
}
|
|
}
|
|
|
|
if (skippedInvalid > 0) {
|
|
logLine(`Skipped ${skippedInvalid} non-replaceable events due to invalid signature/hash.`, 'warn');
|
|
}
|
|
return prepared;
|
|
}
|
|
|
|
function priorityForKind(kind) {
|
|
if (PRIORITY_CRITICAL.has(kind)) return 'critical';
|
|
if (PRIORITY_HIGH.has(kind)) return 'high';
|
|
if (PRIORITY_MEDIUM.has(kind)) return 'medium';
|
|
return 'low';
|
|
}
|
|
|
|
function setStepState(step, stateName) {
|
|
const statusEl = document.getElementById(stepConfig[step].status);
|
|
const buttonEl = document.getElementById(stepConfig[step].button);
|
|
if (!statusEl || !buttonEl) return;
|
|
if (stateName === 'idle') statusEl.textContent = '○';
|
|
if (stateName === 'busy') statusEl.textContent = '◐';
|
|
if (stateName === 'done') statusEl.textContent = '●';
|
|
if (stateName === 'error') statusEl.textContent = '✕';
|
|
}
|
|
|
|
function setEnabled(step, enabled) {
|
|
const buttonEl = document.getElementById(stepConfig[step].button);
|
|
if (buttonEl) buttonEl.disabled = !enabled;
|
|
}
|
|
|
|
function updateStats() {
|
|
document.getElementById('statEvents').textContent = `Events: ${state.allEvents.length}`;
|
|
document.getElementById('statRelays').textContent = `Discovered relays: ${state.discoveredRelays.size}`;
|
|
document.getElementById('statAvailable').textContent = `Available relays: ${state.availableRelays.length}`;
|
|
document.getElementById('statSpread').textContent = `Spread attempts: ${state.spreadStats.attempts} (ok ${state.spreadStats.success} / fail ${state.spreadStats.failed})`;
|
|
}
|
|
|
|
function renderKindsTable() {
|
|
const rows = Array.from(state.eventsByKind.entries()).sort((a, b) => b[1].length - a[1].length);
|
|
kindsBody.innerHTML = rows.map(([kind, events]) => {
|
|
const p = priorityForKind(Number(kind));
|
|
return `<tr><td>${kind}</td><td>${events.length}</td><td>${p}</td></tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function normalizeRelayUrl(url) {
|
|
const trimmed = String(url || '').trim();
|
|
if (!trimmed) return '';
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
if (parsed.protocol !== 'wss:' && parsed.protocol !== 'ws:') return '';
|
|
parsed.hash = '';
|
|
parsed.search = '';
|
|
let normalized = parsed.toString();
|
|
if (normalized.endsWith('/')) normalized = normalized.slice(0, -1);
|
|
return normalized;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function isPrivateOrLocalRelayUrl(relayUrl) {
|
|
try {
|
|
const parsed = new URL(relayUrl);
|
|
const host = String(parsed.hostname || '').toLowerCase();
|
|
if (!host) return true;
|
|
|
|
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
|
|
if (host.endsWith('.local') || host.endsWith('.localdomain') || host.endsWith('.lan') || host.endsWith('.home') || host.endsWith('.internal')) return true;
|
|
if (host.endsWith('.ts.net')) return true; // Tailscale/private tailnet names
|
|
|
|
if (/^10\./.test(host)) return true;
|
|
if (/^127\./.test(host)) return true;
|
|
if (/^192\.168\./.test(host)) return true;
|
|
if (/^169\.254\./.test(host)) return true;
|
|
|
|
const m172 = host.match(/^172\.(\d{1,3})\./);
|
|
if (m172) {
|
|
const second = Number(m172[1]);
|
|
if (second >= 16 && second <= 31) return true;
|
|
}
|
|
|
|
if (host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80:')) return true; // IPv6 ULA/link-local
|
|
return false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function relayToHttp(relayUrl) {
|
|
if (relayUrl.startsWith('wss://')) return relayUrl.replace('wss://', 'https://');
|
|
if (relayUrl.startsWith('ws://')) return relayUrl.replace('ws://', 'http://');
|
|
return relayUrl;
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function nowSec() {
|
|
return Math.floor(Date.now() / 1000);
|
|
}
|
|
|
|
function normalizeStoredRelayMetadata(input = {}, relayUrl = '') {
|
|
const tsNow = nowSec();
|
|
const source = (input && typeof input === 'object') ? input : {};
|
|
|
|
const url = normalizeRelayUrl(source.url || relayUrl);
|
|
const testedLegacy = Number(source.tested || 0);
|
|
const firstSeen = Number(source.firstSeen || testedLegacy || tsNow);
|
|
const lastTested = Number(source.lastTested || testedLegacy || 0);
|
|
const reachable = typeof source.reachable === 'boolean' ? source.reachable : Boolean(source.available);
|
|
const lastReachable = Number(source.lastReachable || (reachable ? (lastTested || tsNow) : 0));
|
|
const authRequired = Boolean(source.authRequired);
|
|
const latencyMs = Number.isFinite(Number(source.latencyMs)) ? Number(source.latencyMs) : null;
|
|
const nip11 = source.nip11 && typeof source.nip11 === 'object' ? source.nip11 : null;
|
|
const geo = source.geo && typeof source.geo === 'object' ? source.geo : null;
|
|
|
|
const responses = {};
|
|
if (source.responses && typeof source.responses === 'object') {
|
|
for (const [kind, response] of Object.entries(source.responses)) {
|
|
if (!response || typeof response !== 'object') continue;
|
|
responses[String(kind)] = {
|
|
ok: Boolean(response.ok),
|
|
msg: String(response.msg || response.reason || ''),
|
|
ts: Number(response.ts || response.tested || 0) || 0
|
|
};
|
|
}
|
|
}
|
|
|
|
if (Object.keys(responses).length === 0 && (source.response || source.testKind != null)) {
|
|
const key = String(Number(source.testKind || 0));
|
|
responses[key] = {
|
|
ok: reachable,
|
|
msg: String(source.response || ''),
|
|
ts: lastTested || tsNow
|
|
};
|
|
}
|
|
|
|
return {
|
|
url,
|
|
firstSeen,
|
|
lastTested,
|
|
lastReachable,
|
|
reachable,
|
|
authRequired,
|
|
latencyMs,
|
|
nip11,
|
|
geo,
|
|
responses
|
|
};
|
|
}
|
|
|
|
function recordRelayResponse(meta, kind, result, ts = nowSec()) {
|
|
const responseMap = (meta.responses && typeof meta.responses === 'object') ? meta.responses : {};
|
|
responseMap[String(Number(kind || 0))] = {
|
|
ok: Boolean(result?.ok),
|
|
msg: String(result?.reason || ''),
|
|
ts
|
|
};
|
|
meta.responses = responseMap;
|
|
return meta;
|
|
}
|
|
|
|
function serializeRelayMetadata(meta) {
|
|
return normalizeStoredRelayMetadata(meta, meta?.url || '');
|
|
}
|
|
|
|
async function fetchNip11(relayUrl) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 4000);
|
|
try {
|
|
const response = await fetch(relayToHttp(relayUrl), {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/nostr+json' },
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok) return null;
|
|
return await response.json();
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function shouldMarkAuthRequired(reason = '') {
|
|
const text = String(reason || '').toLowerCase();
|
|
return text.includes('auth') || text.includes('nip-42') || text.includes('restricted') || text.includes('permission denied');
|
|
}
|
|
|
|
function classifySkipSignal(reason = '', kind = null) {
|
|
const text = String(reason || '').toLowerCase();
|
|
const kindNum = kind == null ? null : Number(kind || 0);
|
|
|
|
const relayWidePolicy = [
|
|
'auth-required',
|
|
'only authorized users can write',
|
|
'private relay',
|
|
'account is timed out',
|
|
'web of trust',
|
|
'policy violated and pubkey is not in our web of trust'
|
|
].some((needle) => text.includes(needle));
|
|
if (relayWidePolicy) {
|
|
return { scope: 'relay', reason: text || 'relay-policy' };
|
|
}
|
|
|
|
const kindPolicy = [
|
|
'blocked kind',
|
|
'kind not allowed',
|
|
'event_too_large',
|
|
'event too large',
|
|
'too many tags'
|
|
].some((needle) => text.includes(needle));
|
|
if (kindPolicy) {
|
|
return { scope: 'kind', kind: kindNum, reason: text || 'kind-policy' };
|
|
}
|
|
|
|
if (text.includes('rate_limit_exceeded') || text.includes('rate limit')) {
|
|
return { scope: 'backoff', backoffMs: 1800, reason: text || 'rate-limit' };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function shouldSkipRelayFromHistory(relayMeta, kind = null) {
|
|
if (!relayMeta || typeof relayMeta !== 'object') return null;
|
|
if (relayMeta.authRequired) return { scope: 'relay', reason: 'auth-required(history)' };
|
|
|
|
const responses = relayMeta.responses && typeof relayMeta.responses === 'object' ? relayMeta.responses : {};
|
|
const kindKey = kind == null ? null : String(Number(kind || 0));
|
|
|
|
for (const [k, resp] of Object.entries(responses)) {
|
|
if (!resp || resp.ok) continue;
|
|
const signal = classifySkipSignal(resp.msg || '', Number(k));
|
|
if (!signal) continue;
|
|
if (signal.scope === 'relay') return { ...signal, source: 'history' };
|
|
if (signal.scope === 'kind' && kindKey != null && String(Number(signal.kind || k)) === kindKey) {
|
|
return { ...signal, source: 'history' };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function sendEventToRelay(relayUrl, event, timeoutMs = RELAY_TEST_TIMEOUT_MS) {
|
|
return await new Promise((resolve) => {
|
|
let settled = false;
|
|
const startedAt = Date.now();
|
|
let ws;
|
|
|
|
const finish = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try { ws?.close(); } catch {}
|
|
resolve(result);
|
|
};
|
|
|
|
const timeout = setTimeout(() => {
|
|
finish({ ok: false, reason: 'timeout', latencyMs: Date.now() - startedAt, authRequired: false });
|
|
}, timeoutMs);
|
|
|
|
try {
|
|
ws = new WebSocket(relayUrl);
|
|
} catch (error) {
|
|
clearTimeout(timeout);
|
|
finish({ ok: false, reason: `socket-error: ${error.message}`, latencyMs: Date.now() - startedAt, authRequired: false });
|
|
return;
|
|
}
|
|
|
|
ws.onopen = () => {
|
|
try {
|
|
ws.send(JSON.stringify(['EVENT', event]));
|
|
} catch (error) {
|
|
clearTimeout(timeout);
|
|
finish({ ok: false, reason: `send-error: ${error.message}`, latencyMs: Date.now() - startedAt, authRequired: false });
|
|
}
|
|
};
|
|
|
|
ws.onmessage = (messageEvent) => {
|
|
try {
|
|
const msg = JSON.parse(messageEvent.data);
|
|
if (!Array.isArray(msg)) return;
|
|
if (msg[0] === 'OK' && msg[1] === event.id) {
|
|
const reason = String(msg[3] || '');
|
|
const authRequired = !Boolean(msg[2]) && shouldMarkAuthRequired(reason);
|
|
clearTimeout(timeout);
|
|
finish({ ok: Boolean(msg[2]), reason, latencyMs: Date.now() - startedAt, authRequired });
|
|
}
|
|
if (msg[0] === 'AUTH') {
|
|
clearTimeout(timeout);
|
|
finish({ ok: false, reason: 'auth-required', latencyMs: Date.now() - startedAt, authRequired: true, authChallenge: String(msg[1] || '') });
|
|
}
|
|
if (msg[0] === 'NOTICE') {
|
|
const reason = String(msg[1] || 'notice');
|
|
clearTimeout(timeout);
|
|
finish({ ok: false, reason, latencyMs: Date.now() - startedAt, authRequired: shouldMarkAuthRequired(reason) });
|
|
}
|
|
} catch {
|
|
// Ignore non-json
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout);
|
|
finish({ ok: false, reason: 'socket-error', latencyMs: Date.now() - startedAt, authRequired: false });
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (!settled) {
|
|
clearTimeout(timeout);
|
|
finish({ ok: false, reason: 'closed-before-ok', latencyMs: Date.now() - startedAt, authRequired: false });
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
function getRelayMetadataSnapshot() {
|
|
return Array.from(state.relayMetadata.values()).sort((a, b) => String(a.url || '').localeCompare(String(b.url || '')));
|
|
}
|
|
|
|
function renderRelayJsonPreview() {
|
|
if (!relayJsonEl) return;
|
|
const chunks = buildRelayChunks();
|
|
const previewPayload = {
|
|
kind: 30078,
|
|
tag: RELAY_LIST_TAG,
|
|
totalRelays: state.relayMetadata.size,
|
|
chunkCount: chunks.length,
|
|
chunks: chunks.map((chunk, idx) => ({
|
|
d: `${RELAY_LIST_D_PREFIX}${pad3(idx + 1)}`,
|
|
count: chunk.length,
|
|
data: chunk
|
|
}))
|
|
};
|
|
relayJsonEl.textContent = JSON.stringify(previewPayload, null, 2);
|
|
}
|
|
|
|
function eventFitsRelay(event, relayMeta) {
|
|
const lim = relayMeta?.nip11?.limitation;
|
|
if (!lim) return true;
|
|
const maxTags = Number(lim.max_event_tags || 0);
|
|
const maxContent = Number(lim.max_content_length || 0);
|
|
if (maxTags > 0 && (event.tags || []).length > maxTags) return false;
|
|
if (maxContent > 0 && String(event.content || '').length > maxContent) return false;
|
|
return true;
|
|
}
|
|
|
|
async function stepDiagnose737x() {
|
|
setStepState('diagnose', 'busy');
|
|
resetHalt();
|
|
try {
|
|
logLine('Running diagnostics for kinds 7375/7376. Detailed output will be in browser console.', 'warn');
|
|
const events = await ndkFetchEvents({ authors: [state.pubkey], kinds: [7375, 7376], limit: 1000 });
|
|
const list = Array.isArray(events) ? events : [];
|
|
|
|
const missingCounts = {
|
|
id: 0,
|
|
sig: 0,
|
|
pubkey: 0,
|
|
created_at: 0,
|
|
kind: 0
|
|
};
|
|
const summary = {
|
|
total: list.length,
|
|
byKind: { '7375': 0, '7376': 0, other: 0 },
|
|
missingCore: 0,
|
|
invalidByLocalVerify: 0
|
|
};
|
|
const rows = [];
|
|
|
|
for (let i = 0; i < list.length; i++) {
|
|
throwIfHalted();
|
|
const original = list[i];
|
|
const raw = extractRawEventCandidate(original);
|
|
const canonical = canonicalizeNostrEvent(original);
|
|
const verify = validateEventSignatureLocally(canonical);
|
|
|
|
const kindNum = Number(canonical.kind || original?.kind || 0);
|
|
if (kindNum === 7375) summary.byKind['7375']++;
|
|
else if (kindNum === 7376) summary.byKind['7376']++;
|
|
else summary.byKind.other++;
|
|
|
|
const hasId = Boolean(canonical.id);
|
|
const hasSig = Boolean(canonical.sig);
|
|
const hasPubkey = Boolean(canonical.pubkey);
|
|
const hasCreatedAt = Boolean(Number(canonical.created_at || 0));
|
|
const hasKind = Boolean(Number(canonical.kind || 0));
|
|
const missingAny = !(hasId && hasSig && hasPubkey && hasCreatedAt && hasKind);
|
|
|
|
if (!hasId) missingCounts.id++;
|
|
if (!hasSig) missingCounts.sig++;
|
|
if (!hasPubkey) missingCounts.pubkey++;
|
|
if (!hasCreatedAt) missingCounts.created_at++;
|
|
if (!hasKind) missingCounts.kind++;
|
|
if (missingAny) summary.missingCore++;
|
|
if (!verify.ok) summary.invalidByLocalVerify++;
|
|
|
|
rows.push({
|
|
idx: i,
|
|
sourceKind: Number(original?.kind || 0),
|
|
canonicalKind: kindNum,
|
|
rawType: raw?.constructor?.name || typeof raw,
|
|
rawKeys: raw && typeof raw === 'object' ? Object.keys(raw).slice(0, 12).join(',') : '',
|
|
hasRawId: Boolean(raw?.id),
|
|
hasRawSig: Boolean(raw?.sig),
|
|
hasRawPubkey: Boolean(raw?.pubkey),
|
|
hasCanonicalId: hasId,
|
|
hasCanonicalSig: hasSig,
|
|
hasCanonicalPubkey: hasPubkey,
|
|
hasCanonicalCreatedAt: hasCreatedAt,
|
|
hasCanonicalKind: hasKind,
|
|
contentLen: String(canonical.content || '').length,
|
|
tagsLen: Array.isArray(canonical.tags) ? canonical.tags.length : 0,
|
|
verifyOk: verify.ok,
|
|
verifyReason: verify.reason,
|
|
idPrefix: String(canonical.id || '').slice(0, 12)
|
|
});
|
|
}
|
|
|
|
console.groupCollapsed(`[keep-alive] 737x diagnostics (${summary.total} events)`);
|
|
console.log('summary', summary);
|
|
console.log('missingCoreFieldCounts', missingCounts);
|
|
console.table(rows.slice(0, 120));
|
|
if (rows.length > 120) {
|
|
console.log(`rows truncated in console.table: showing first 120/${rows.length}`);
|
|
}
|
|
console.groupEnd();
|
|
|
|
logLine(`Diagnose complete: ${summary.total} events (missing core: ${summary.missingCore}, verify-fail: ${summary.invalidByLocalVerify}).`, summary.missingCore > 0 ? 'warn' : 'ok');
|
|
setStepState('diagnose', 'done');
|
|
} catch (error) {
|
|
if (error?.message === 'HALTED_BY_USER') {
|
|
logLine('Diagnostics halted by user.', 'warn');
|
|
setStepState('diagnose', 'idle');
|
|
return;
|
|
}
|
|
logLine(`Diagnostics failed: ${error.message}`, 'error');
|
|
setStepState('diagnose', 'error');
|
|
}
|
|
}
|
|
|
|
async function stepFindEvents() {
|
|
setStepState('findEvents', 'busy');
|
|
resetHalt();
|
|
try {
|
|
logLine('Fetching all events authored by current user...');
|
|
const events = await ndkFetchEvents({ authors: [state.pubkey], limit: 5000 });
|
|
state.allEvents = Array.isArray(events) ? events : [];
|
|
|
|
state.eventsByKind = new Map();
|
|
state.eventsByPriority = { critical: [], high: [], medium: [], low: [] };
|
|
|
|
for (const ev of state.allEvents) {
|
|
const k = Number(ev.kind || 0);
|
|
if (!state.eventsByKind.has(k)) state.eventsByKind.set(k, []);
|
|
state.eventsByKind.get(k).push(ev);
|
|
state.eventsByPriority[priorityForKind(k)].push(ev);
|
|
}
|
|
|
|
state.kind0Event = (state.eventsByKind.get(0) || []).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0] || null;
|
|
if (!state.kind0Event) logLine('No kind 0 found. Relay testing step requires a profile event.', 'error');
|
|
|
|
renderKindsTable();
|
|
updateStats();
|
|
logLine(`Found ${state.allEvents.length} events across ${state.eventsByKind.size} kinds.`, 'ok');
|
|
|
|
setEnabled('findRelays', true);
|
|
setStepState('findEvents', 'done');
|
|
} catch (error) {
|
|
logLine(`Find events failed: ${error.message}`, 'error');
|
|
setStepState('findEvents', 'error');
|
|
}
|
|
}
|
|
|
|
async function loadStoredRelayRegistry() {
|
|
try {
|
|
const registryEvents = await ndkFetchEvents({ kinds: [30078], authors: [state.pubkey], '#t': [RELAY_LIST_TAG], limit: 200 });
|
|
const deduped = new Set();
|
|
for (const ev of registryEvents || []) {
|
|
try {
|
|
const arr = JSON.parse(String(ev.content || '[]'));
|
|
if (!Array.isArray(arr)) continue;
|
|
for (const item of arr) {
|
|
const n = normalizeRelayUrl(item?.url);
|
|
if (!n) continue;
|
|
if (isPrivateOrLocalRelayUrl(n)) continue;
|
|
deduped.add(n);
|
|
const normalizedMeta = normalizeStoredRelayMetadata(item, n);
|
|
if (!normalizedMeta.url) continue;
|
|
if (!state.relayMetadata.has(n)) {
|
|
state.relayMetadata.set(n, normalizedMeta);
|
|
} else {
|
|
const merged = {
|
|
...normalizeStoredRelayMetadata(state.relayMetadata.get(n), n),
|
|
...normalizedMeta,
|
|
responses: {
|
|
...normalizeStoredRelayMetadata(state.relayMetadata.get(n), n).responses,
|
|
...normalizedMeta.responses
|
|
}
|
|
};
|
|
state.relayMetadata.set(n, normalizeStoredRelayMetadata(merged, n));
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
if (deduped.size > 0) logLine(`Loaded ${deduped.size} relays from stored #t:${RELAY_LIST_TAG} registry.`, 'ok');
|
|
renderRelayJsonPreview();
|
|
return deduped;
|
|
} catch (error) {
|
|
logLine(`Could not load stored registry: ${error.message}`, 'error');
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
async function stepLoadRegistry() {
|
|
setStepState('loadRegistry', 'busy');
|
|
resetHalt();
|
|
try {
|
|
const fromRegistry = await loadStoredRelayRegistry();
|
|
for (const relay of fromRegistry) state.discoveredRelays.add(relay);
|
|
|
|
const sortedRelays = Array.from(state.discoveredRelays).sort((a, b) => a.localeCompare(b));
|
|
if (sortedRelays.length > FIND_RELAYS_TEST_LIMIT) {
|
|
state.discoveredRelays = new Set(sortedRelays.slice(0, FIND_RELAYS_TEST_LIMIT));
|
|
logLine(`Temporary test cap applied: using first ${FIND_RELAYS_TEST_LIMIT}/${sortedRelays.length} discovered relays.`, 'warn');
|
|
}
|
|
|
|
updateStats();
|
|
if (fromRegistry.size === 0) {
|
|
logLine(`No saved #t:${RELAY_LIST_TAG} registry entries found.`, 'warn');
|
|
}
|
|
|
|
setEnabled('testRelays', state.kind0Event != null && state.discoveredRelays.size > 0);
|
|
setStepState('loadRegistry', 'done');
|
|
} catch (error) {
|
|
if (error?.message === 'HALTED_BY_USER') {
|
|
logLine('Load registry halted by user.', 'warn');
|
|
setStepState('loadRegistry', 'idle');
|
|
return;
|
|
}
|
|
logLine(`Load registry failed: ${error.message}`, 'error');
|
|
setStepState('loadRegistry', 'error');
|
|
}
|
|
}
|
|
|
|
async function stepFindRelays() {
|
|
setStepState('findRelays', 'busy');
|
|
resetHalt();
|
|
try {
|
|
logLine('Reading contacts (kind 3) to get follow list...');
|
|
const contacts = await ndkFetchEvents({ kinds: [3], authors: [state.pubkey], limit: 5 });
|
|
const latestContacts = (contacts || []).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
|
|
const follows = new Set();
|
|
|
|
for (const tag of latestContacts?.tags || []) {
|
|
if (tag[0] === 'p' && tag[1]) follows.add(String(tag[1]).trim());
|
|
}
|
|
|
|
logLine(`Found ${follows.size} follows. Fetching their kind 10002 relay lists...`);
|
|
|
|
let skippedPrivateRelays = 0;
|
|
const followPubkeys = Array.from(follows);
|
|
for (let i = 0; i < followPubkeys.length; i += 100) {
|
|
throwIfHalted();
|
|
const chunk = followPubkeys.slice(i, i + 100);
|
|
const relayListEvents = await ndkFetchEvents({ kinds: [10002], authors: chunk, limit: 3000 });
|
|
for (const ev of relayListEvents || []) {
|
|
for (const tag of ev.tags || []) {
|
|
if (tag[0] !== 'r' || !tag[1]) continue;
|
|
const normalized = normalizeRelayUrl(tag[1]);
|
|
if (!normalized) continue;
|
|
if (isPrivateOrLocalRelayUrl(normalized)) {
|
|
skippedPrivateRelays++;
|
|
continue;
|
|
}
|
|
state.discoveredRelays.add(normalized);
|
|
}
|
|
}
|
|
logLine(`Processed follows ${i + 1}-${Math.min(i + 100, followPubkeys.length)}.`);
|
|
}
|
|
|
|
const fromRegistry = await loadStoredRelayRegistry();
|
|
for (const relay of fromRegistry) state.discoveredRelays.add(relay);
|
|
|
|
const sortedRelays = Array.from(state.discoveredRelays).sort((a, b) => a.localeCompare(b));
|
|
if (sortedRelays.length > FIND_RELAYS_TEST_LIMIT) {
|
|
state.discoveredRelays = new Set(sortedRelays.slice(0, FIND_RELAYS_TEST_LIMIT));
|
|
logLine(`Temporary test cap applied: using first ${FIND_RELAYS_TEST_LIMIT}/${sortedRelays.length} discovered relays.`, 'warn');
|
|
}
|
|
|
|
updateStats();
|
|
logLine(`Discovered ${state.discoveredRelays.size} unique relays.`, 'ok');
|
|
if (skippedPrivateRelays > 0) {
|
|
logLine(`Skipped ${skippedPrivateRelays} local/private relay URLs (localhost, LAN, .local, .ts.net, RFC1918).`, 'warn');
|
|
}
|
|
|
|
setEnabled('testRelays', state.kind0Event != null && state.discoveredRelays.size > 0);
|
|
setStepState('findRelays', 'done');
|
|
} catch (error) {
|
|
if (error?.message === 'HALTED_BY_USER') {
|
|
logLine('Find relays halted by user.', 'warn');
|
|
setStepState('findRelays', 'idle');
|
|
return;
|
|
}
|
|
logLine(`Find relays failed: ${error.message}`, 'error');
|
|
setStepState('findRelays', 'error');
|
|
}
|
|
}
|
|
|
|
async function stepTestRelays() {
|
|
setStepState('testRelays', 'busy');
|
|
resetHalt();
|
|
try {
|
|
if (!state.kind0Event) throw new Error('No kind 0 event available for relay testing');
|
|
if (state.discoveredRelays.size === 0) throw new Error('No discovered relays to test');
|
|
|
|
state.availableRelays = [];
|
|
const relayList = Array.from(state.discoveredRelays);
|
|
logLine(`Testing ${relayList.length} relays using your kind 0 event...`);
|
|
logLine('Successes are marked with ✓ and failures with ✕. Many dead relays are expected.', 'warn');
|
|
|
|
for (let i = 0; i < relayList.length; i++) {
|
|
throwIfHalted();
|
|
const relayUrl = relayList[i];
|
|
const signedKind0 = canonicalizeNostrEvent(await maybeResignEvent(state.kind0Event));
|
|
const [testResult, nip11] = await Promise.all([
|
|
sendEventToRelay(relayUrl, signedKind0, RELAY_TEST_TIMEOUT_MS),
|
|
fetchNip11(relayUrl)
|
|
]);
|
|
|
|
const ts = nowSec();
|
|
const previousMeta = normalizeStoredRelayMetadata(state.relayMetadata.get(relayUrl), relayUrl);
|
|
const meta = {
|
|
...previousMeta,
|
|
url: relayUrl,
|
|
firstSeen: previousMeta.firstSeen || ts,
|
|
lastTested: ts,
|
|
lastReachable: testResult.ok ? ts : previousMeta.lastReachable,
|
|
reachable: Boolean(testResult.ok),
|
|
authRequired: previousMeta.authRequired || Boolean(testResult.authRequired),
|
|
latencyMs: Number.isFinite(Number(testResult.latencyMs)) ? Number(testResult.latencyMs) : previousMeta.latencyMs,
|
|
nip11: nip11 || previousMeta.nip11 || null,
|
|
geo: previousMeta.geo || null,
|
|
responses: { ...(previousMeta.responses || {}) }
|
|
};
|
|
|
|
recordRelayResponse(meta, 0, testResult, ts);
|
|
state.relayMetadata.set(relayUrl, serializeRelayMetadata(meta));
|
|
if (meta.reachable) state.availableRelays.push(relayUrl);
|
|
|
|
const marker = meta.reachable ? 'ok' : (meta.authRequired ? 'warn' : 'error');
|
|
logLine(`[${i + 1}/${relayList.length}] ${relayUrl} -> ${meta.reachable ? 'available' : (meta.authRequired ? 'auth-required' : 'unavailable')} (${testResult.reason || 'no-reason'})`, marker);
|
|
updateStats();
|
|
renderRelayJsonPreview();
|
|
await delay(100);
|
|
}
|
|
|
|
logLine(`Relay testing complete. Available: ${state.availableRelays.length}/${relayList.length}`, 'ok');
|
|
setEnabled('saveRegistry', state.relayMetadata.size > 0);
|
|
setEnabled('spreadCritical', state.availableRelays.length > 0);
|
|
setStepState('testRelays', 'done');
|
|
} catch (error) {
|
|
if (error?.message === 'HALTED_BY_USER') {
|
|
logLine('Relay testing halted by user.', 'warn');
|
|
setStepState('testRelays', 'idle');
|
|
return;
|
|
}
|
|
logLine(`Test relays failed: ${error.message}`, 'error');
|
|
setStepState('testRelays', 'error');
|
|
}
|
|
}
|
|
|
|
function buildRelayChunks() {
|
|
const allMeta = Array.from(state.relayMetadata.values())
|
|
.map((meta) => serializeRelayMetadata(meta))
|
|
.filter((meta) => Boolean(meta.url))
|
|
.sort((a, b) => a.url.localeCompare(b.url));
|
|
const chunks = [];
|
|
for (let i = 0; i < allMeta.length; i += CHUNK_SIZE) {
|
|
chunks.push(allMeta.slice(i, i + CHUNK_SIZE));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function pad3(n) {
|
|
return String(n).padStart(3, '0');
|
|
}
|
|
|
|
async function stepSaveRegistry() {
|
|
setStepState('saveRegistry', 'busy');
|
|
resetHalt();
|
|
try {
|
|
const chunks = buildRelayChunks();
|
|
if (chunks.length === 0) throw new Error('No relay metadata to save');
|
|
renderRelayJsonPreview();
|
|
|
|
logLine(`Publishing relay registry chunks with #t:${RELAY_LIST_TAG}...`);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
throwIfHalted();
|
|
const dTag = `${RELAY_LIST_D_PREFIX}${pad3(i + 1)}`;
|
|
const event = {
|
|
kind: 30078,
|
|
created_at: now,
|
|
tags: [
|
|
['d', dTag],
|
|
['t', RELAY_LIST_TAG],
|
|
['index', String(i + 1)],
|
|
['total', String(chunks.length)]
|
|
],
|
|
content: JSON.stringify(chunks[i])
|
|
};
|
|
await publishEvent(event);
|
|
logLine(`Published chunk ${i + 1}/${chunks.length} (${dTag}).`, 'ok');
|
|
await delay(250);
|
|
}
|
|
|
|
// Optional cleanup for older extra chunks
|
|
const existing = await ndkFetchEvents({ kinds: [30078], authors: [state.pubkey], '#t': [RELAY_LIST_TAG], limit: 500 });
|
|
const newestByD = new Map();
|
|
for (const ev of existing || []) {
|
|
const d = (ev.tags || []).find((t) => t[0] === 'd')?.[1];
|
|
if (!d || !d.startsWith(RELAY_LIST_D_PREFIX)) continue;
|
|
const prev = newestByD.get(d);
|
|
if (!prev || (ev.created_at || 0) > (prev.created_at || 0)) newestByD.set(d, ev);
|
|
}
|
|
|
|
const validD = new Set(chunks.map((_, i) => `${RELAY_LIST_D_PREFIX}${pad3(i + 1)}`));
|
|
const toDelete = Array.from(newestByD.values()).filter((ev) => {
|
|
const d = (ev.tags || []).find((t) => t[0] === 'd')?.[1];
|
|
return !validD.has(d);
|
|
});
|
|
|
|
if (toDelete.length > 0) {
|
|
const deleteEvent = {
|
|
kind: 5,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: toDelete.map((ev) => ['e', ev.id]),
|
|
content: `keep-alive prune old ${RELAY_LIST_TAG} chunks`
|
|
};
|
|
await publishEvent(deleteEvent);
|
|
logLine(`Published deletion event for ${toDelete.length} obsolete registry chunks.`);
|
|
}
|
|
|
|
setEnabled('spreadCritical', state.availableRelays.length > 0);
|
|
setStepState('saveRegistry', 'done');
|
|
} catch (error) {
|
|
if (error?.message === 'HALTED_BY_USER') {
|
|
logLine('Save registry halted by user.', 'warn');
|
|
setStepState('saveRegistry', 'idle');
|
|
return;
|
|
}
|
|
logLine(`Save registry failed: ${error.message}`, 'error');
|
|
setStepState('saveRegistry', 'error');
|
|
}
|
|
}
|
|
|
|
async function spreadPriority(priorityName, stepName, nextStepName) {
|
|
setStepState(stepName, 'busy');
|
|
resetHalt();
|
|
try {
|
|
const events = state.eventsByPriority[priorityName] || [];
|
|
if (events.length === 0) {
|
|
logLine(`No ${priorityName} events to spread.`, 'ok');
|
|
setStepState(stepName, 'done');
|
|
if (nextStepName) setEnabled(nextStepName, true);
|
|
return;
|
|
}
|
|
|
|
if (state.availableRelays.length === 0) throw new Error('No available relays');
|
|
|
|
logLine(`Spreading ${events.length} ${priorityName} events to ${state.availableRelays.length} relays...`);
|
|
logLine('Preparing events: replaceable kinds will be re-signed with fresh created_at.', 'warn');
|
|
const preparedEvents = await prepareEventsForRelayPublishing(events);
|
|
|
|
for (const relayUrl of state.availableRelays) {
|
|
throwIfHalted();
|
|
const relayMeta = normalizeStoredRelayMetadata(state.relayMetadata.get(relayUrl), relayUrl);
|
|
let relayOk = 0;
|
|
let relayFail = 0;
|
|
let skippedByHistory = 0;
|
|
const blockedKindsThisRelay = new Set();
|
|
|
|
const relayHistorySkip = shouldSkipRelayFromHistory(relayMeta, null);
|
|
if (relayHistorySkip?.scope === 'relay') {
|
|
logLine(`SKIP ${relayUrl} from prior history: ${relayHistorySkip.reason}`, 'warn');
|
|
continue;
|
|
}
|
|
|
|
for (const ev of preparedEvents) {
|
|
throwIfHalted();
|
|
const ts = nowSec();
|
|
|
|
if (blockedKindsThisRelay.has(Number(ev.kind))) {
|
|
skippedByHistory++;
|
|
continue;
|
|
}
|
|
|
|
const historicalKindSkip = shouldSkipRelayFromHistory(relayMeta, ev.kind);
|
|
if (historicalKindSkip?.scope === 'kind') {
|
|
skippedByHistory++;
|
|
logLine(`SKIP ${relayUrl} kind ${ev.kind} from prior history: ${historicalKindSkip.reason}`, 'warn');
|
|
continue;
|
|
}
|
|
|
|
if (!eventFitsRelay(ev, relayMeta)) {
|
|
relayFail++;
|
|
state.spreadStats.failed++;
|
|
state.spreadStats.attempts++;
|
|
recordRelayResponse(relayMeta, ev.kind, { ok: false, reason: 'blocked-by-nip11-limit' }, ts);
|
|
relayMeta.lastTested = ts;
|
|
state.relayMetadata.set(relayUrl, serializeRelayMetadata(relayMeta));
|
|
blockedKindsThisRelay.add(Number(ev.kind));
|
|
continue;
|
|
}
|
|
|
|
const result = await sendEventToRelay(relayUrl, ev, 6000);
|
|
state.spreadStats.attempts++;
|
|
relayMeta.lastTested = ts;
|
|
relayMeta.reachable = Boolean(result.ok);
|
|
if (result.ok) {
|
|
relayOk++;
|
|
state.spreadStats.success++;
|
|
relayMeta.lastReachable = ts;
|
|
logLine(`SUCCESS ${relayUrl} accepted kind ${ev.kind} (${String(ev.id || '').slice(0, 8)}...)`, 'ok');
|
|
} else {
|
|
relayFail++;
|
|
state.spreadStats.failed++;
|
|
if (result.authRequired) relayMeta.authRequired = true;
|
|
logLine(`FAIL ${relayUrl} rejected kind ${ev.kind}: ${result.reason || 'unknown'}`, 'error');
|
|
}
|
|
|
|
if (Number.isFinite(Number(result.latencyMs))) relayMeta.latencyMs = Number(result.latencyMs);
|
|
recordRelayResponse(relayMeta, ev.kind, result, ts);
|
|
state.relayMetadata.set(relayUrl, serializeRelayMetadata(relayMeta));
|
|
|
|
const signal = result.ok ? null : classifySkipSignal(result.reason || '', ev.kind);
|
|
if (signal?.scope === 'relay') {
|
|
logLine(`STOP relay ${relayUrl} for this run: ${signal.reason}`, 'warn');
|
|
updateStats();
|
|
renderRelayJsonPreview();
|
|
break;
|
|
}
|
|
if (signal?.scope === 'kind' && Number.isFinite(Number(signal.kind))) {
|
|
blockedKindsThisRelay.add(Number(signal.kind));
|
|
logLine(`STOP kind ${signal.kind} on ${relayUrl} for this run: ${signal.reason}`, 'warn');
|
|
}
|
|
|
|
updateStats();
|
|
renderRelayJsonPreview();
|
|
const waitMs = signal?.scope === 'backoff'
|
|
? Math.max(SPREAD_DELAY_MS, Number(signal.backoffMs || SPREAD_DELAY_MS))
|
|
: SPREAD_DELAY_MS;
|
|
await delay(waitMs);
|
|
}
|
|
|
|
if (skippedByHistory > 0) {
|
|
logLine(`${priorityName} history-skip -> ${relayUrl}: skipped ${skippedByHistory}`, 'warn');
|
|
}
|
|
logLine(`${priorityName} summary -> ${relayUrl}: ok ${relayOk}, fail ${relayFail}`, relayOk > 0 ? 'ok' : 'warn');
|
|
}
|
|
|
|
logLine(`${priorityName} spread complete.`, 'ok');
|
|
setStepState(stepName, 'done');
|
|
if (nextStepName) setEnabled(nextStepName, true);
|
|
} catch (error) {
|
|
if (error?.message === 'HALTED_BY_USER') {
|
|
logLine(`Spread ${priorityName} halted by user.`, 'warn');
|
|
setStepState(stepName, 'idle');
|
|
return;
|
|
}
|
|
logLine(`Spread ${priorityName} failed: ${error.message}`, 'error');
|
|
setStepState(stepName, 'error');
|
|
}
|
|
}
|
|
|
|
async function updateFooter() {
|
|
try {
|
|
await updateFooterRelayStatus();
|
|
await updateSidenavRelaySection();
|
|
await updateBlossomSection();
|
|
const relayData = await getRelayData();
|
|
divFooterCenter.textContent = state.pubkey ? state.pubkey.slice(0, 12) + '...' : '';
|
|
divFooterRight.textContent = `${relayData?.connectedCount || 0}/${relayData?.totalCount || 0} relays`;
|
|
} catch (error) {
|
|
console.warn('[keep-alive] Footer update skipped:', error.message);
|
|
}
|
|
}
|
|
|
|
function initHamburgerMenu() {
|
|
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
hamburgerInstance.animateTo('burger');
|
|
}
|
|
|
|
function openNav() {
|
|
divSideNav.style.zIndex = 3;
|
|
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
|
|
isNavOpen = true;
|
|
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
|
|
|
|
if (!logoutHamburger) {
|
|
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
|
size: 24,
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
logoutHamburger.animateTo('x');
|
|
}
|
|
|
|
if (!themeToggleHamburger) {
|
|
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
|
size: 24,
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
isDarkMode = localStorage.getItem('theme') === 'dark' || document.body.classList.contains('dark-mode');
|
|
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
|
}
|
|
}
|
|
|
|
function closeNav() {
|
|
divSideNav.style.width = '0vw';
|
|
divSideNav.style.zIndex = -1;
|
|
isNavOpen = false;
|
|
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
|
|
}
|
|
|
|
function toggleNav() {
|
|
if (isNavOpen) closeNav(); else openNav();
|
|
}
|
|
|
|
async function logoutAll() {
|
|
if (state.updateIntervalId) clearInterval(state.updateIntervalId);
|
|
disconnect();
|
|
if (window.NOSTR_LOGIN_LITE?.logout) await window.NOSTR_LOGIN_LITE.logout();
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
location.reload();
|
|
}
|
|
|
|
function wireButtons() {
|
|
document.getElementById('btnHalt').addEventListener('click', requestHalt);
|
|
document.getElementById('btnLoadRegistry').addEventListener('click', stepLoadRegistry);
|
|
document.getElementById('btnFindEvents').addEventListener('click', stepFindEvents);
|
|
document.getElementById('btnDiagnose').addEventListener('click', stepDiagnose737x);
|
|
document.getElementById('btnFindRelays').addEventListener('click', stepFindRelays);
|
|
document.getElementById('btnTestRelays').addEventListener('click', stepTestRelays);
|
|
document.getElementById('btnSaveRegistry').addEventListener('click', stepSaveRegistry);
|
|
document.getElementById('btnSpreadCritical').addEventListener('click', () => spreadPriority('critical', 'spreadCritical', 'spreadHigh'));
|
|
document.getElementById('btnSpreadHigh').addEventListener('click', () => spreadPriority('high', 'spreadHigh', 'spreadMedium'));
|
|
document.getElementById('btnSpreadMedium').addEventListener('click', () => spreadPriority('medium', 'spreadMedium', 'spreadLow'));
|
|
document.getElementById('btnSpreadLow').addEventListener('click', () => spreadPriority('low', 'spreadLow', null));
|
|
}
|
|
|
|
(async function main() {
|
|
try {
|
|
const versionInfo = await getVersion();
|
|
console.log(`[keep-alive ${versionInfo.VERSION}] Loading...`);
|
|
|
|
initHamburgerMenu();
|
|
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
|
|
|
|
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
|
|
isDarkMode = !isDarkMode;
|
|
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
|
document.documentElement.classList.toggle('dark-mode', isDarkMode);
|
|
document.body.classList.toggle('dark-mode', isDarkMode);
|
|
if (themeToggleHamburger) {
|
|
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
|
}
|
|
});
|
|
|
|
document.getElementById('logoutButton')?.addEventListener('click', async () => {
|
|
try { await logoutAll(); } catch (error) { logLine(`Logout failed: ${error.message}`, 'error'); }
|
|
});
|
|
|
|
await initNDKPage();
|
|
state.pubkey = await getPubkey();
|
|
await injectHeaderAvatar(state.pubkey);
|
|
|
|
initFooterRelayStatus();
|
|
initSidenavRelaySection();
|
|
await initBlossomSection();
|
|
initAiSectionWithLocalConfig();
|
|
|
|
await updateFooter();
|
|
state.updateIntervalId = setInterval(updateFooter, 1000);
|
|
|
|
wireButtons();
|
|
updateStats();
|
|
renderRelayJsonPreview();
|
|
await updateVersionDisplay();
|
|
|
|
|
|
logLine('Keep-alive ready. Start with: Find My Events');
|
|
} catch (error) {
|
|
console.error('[keep-alive] Initialization failed:', error);
|
|
divBody.innerHTML = `<div style="padding:20px;color:red;">Initialization failed: ${String(error.message || error)}</div>`;
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|