Browse Source

making monitor more advanced

imwald
Silberengel 1 month ago
parent
commit
8cdc403d9b
  1. 11
      docker-compose.prod.yml
  2. 228
      nip66-cron/index.mjs
  3. 4
      package-lock.json
  4. 2
      package.json
  5. 44
      src/components/ui/dropdown-menu.tsx

11
docker-compose.prod.yml

@ -33,9 +33,14 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- NIP66_MONITOR_NSEC=${NIP66_MONITOR_NSEC} - NIP66_MONITOR_NSEC=${NIP66_MONITOR_NSEC}
# Optional: RELAYS_TO_MONITOR=wss://relay1,wss://relay2 (default: built-in list) - RELAYS_TO_MONITOR=${RELAYS_TO_MONITOR:-}
# Optional: PUBLISH_RELAYS=wss://... (default: built-in list) - RELAY_LIST_SKIP_KIND10002=${RELAY_LIST_SKIP_KIND10002:-}
# Optional: INTERVAL_MS=3600000 (default: 1 hour between 30166 runs) - MAX_RELAYS_TO_MONITOR=${MAX_RELAYS_TO_MONITOR:-}
- PUBLISH_RELAYS=${PUBLISH_RELAYS:-}
- INTERVAL_MS=${INTERVAL_MS:-}
# Default: merge built-in relay list + monitor’s kind 10002 (`r` tags). See nip66-cron/index.mjs.
# - RELAYS_TO_MONITOR — optional; replaces merged list entirely
# - RELAY_LIST_SKIP_KIND10002=1 — defaults only (no 10002 fetch)
logging: logging:
driver: json-file driver: json-file
options: options:

228
nip66-cron/index.mjs

@ -1,38 +1,89 @@
/** /**
* NIP-66 relay monitor cron. Runs on the server; nsec stays in env, never sent to client. * NIP-66 relay monitor cron. Runs on the server; nsec stays in env, never sent to client.
* - On startup: publish kind 10166 (monitor announcement) once. * - On startup: publish kind 10166 (monitor announcement) once.
* - Every INTERVAL_MS: for each relay in RELAYS_TO_MONITOR, fetch NIP-11, build & publish 30166. * - Every INTERVAL_MS: for each relay in the resolved monitor list, fetch NIP-11, build & publish 30166.
*
* Which relays are monitored:
* 1) If RELAYS_TO_MONITOR is set: use that comma-separated list only (operator override).
* 2) Else (default): merge built-in DEFAULT_RELAYS_TO_MONITOR with the monitor accounts kind 10002
* (`r` tags), deduped (defaults first, then 10002-only URLs). If no 10002 is found or it has no `r`
* URLs, use defaults only.
* Set RELAY_LIST_SKIP_KIND10002=1 to skip fetching 10002 and use defaults only.
*
* nostr.watch (and similar) only show relays that received a 30166. Relays whose NIP-11 HTTPS fetch
* fails from this container are skipped check logs for "NIP-11 fetch failed" / "Skipping relay".
* *
* Env: * Env:
* NIP66_MONITOR_NSEC - required; nsec for signing 30166/10166 * NIP66_MONITOR_NSEC - required; nsec for signing 30166/10166 (also used to find kind 10002 author)
* RELAYS_TO_MONITOR - optional; comma-separated wss:// URLs. Default: built-in list. * RELAYS_TO_MONITOR - optional; if set, replaces merged list (static URLs only)
* PUBLISH_RELAYS - optional; comma-separated relays to publish to. Default: built-in list. * RELAY_LIST_SKIP_KIND10002 - optional; "1"/"true" = do not fetch kind 10002; defaults only
* INTERVAL_MS - optional; ms between full monitor runs (default 3600000 = 1h) * PUBLISH_RELAYS - optional; comma-separated relays to publish/query / REQ 10002
* MAX_RELAYS_TO_MONITOR - optional; cap after merge (default 500)
* INTERVAL_MS - optional; ms between full monitor runs (default 900000 = 15m)
*/ */
import { finalizeEvent, nip19 } from 'nostr-tools' import { finalizeEvent, getPublicKey, nip19 } from 'nostr-tools'
import WebSocket from 'ws' import WebSocket from 'ws'
const RELAY_DISCOVERY_KIND = 30166 const RELAY_DISCOVERY_KIND = 30166
const RELAY_MONITOR_ANNOUNCEMENT_KIND = 10166 const RELAY_MONITOR_ANNOUNCEMENT_KIND = 10166
/**
* Default URLs to run NIP-11 checks against (30166); always merged with the monitors kind 10002 unless overridden.
* Union of relay presets in src/constants.ts: DEFAULT_FAVORITE_RELAYS, BIG_RELAY_URLS,
* NIP66_DISCOVERY_RELAY_URLS, BOOKSTR_RELAY_URLS, READ_ONLY_RELAY_URLS, KIND_1_BLOCKED_RELAY_URLS,
* FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS, SEARCHABLE_RELAY_URLS,
* PROFILE_RELAY_URLS, DEFAULT_NOSTRCONNECT_RELAY deduped, sorted.
*/
const DEFAULT_RELAYS_TO_MONITOR = [ const DEFAULT_RELAYS_TO_MONITOR = [
'wss://theforest.nostr1.com', 'wss://aggr.nostr.land',
'wss://orly-relay.imwald.eu', 'wss://bucket.coracle.social',
'wss://freelay.sovbit.host',
'wss://nostr.sovbit.host',
'wss://hist.nostr.land',
'wss://nos.lol',
'wss://nostr.land', 'wss://nostr.land',
'wss://thecitadel.nostr1.com', 'wss://nostr.mom',
'wss://nostr.wine',
'wss://relay.lumina.rocks',
'wss://greensoul.space',
'wss://nostr21.com',
'wss://orly-relay.imwald.eu',
'wss://profiles.nostr1.com',
'wss://purplepag.es',
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://relay.gifbuddy.lol',
'wss://relay.nostr.watch',
'wss://relay.nsec.app',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://nos.lol' 'wss://relay.snort.social',
'wss://relaypag.es',
'wss://search.nos.today',
'wss://thecitadel.nostr1.com',
'wss://theforest.nostr1.com',
'wss://christpill.nostr1.com',
'wss://nostr.einundzwanzig.space',
'relay.wikifreedia.xyz'
] ]
/** Relays to publish 30166/10166 and to REQ kind 10002 from; broad enough for Imwald + NIP-66 discovery. */
const DEFAULT_PUBLISH_RELAYS = [ const DEFAULT_PUBLISH_RELAYS = [
'wss://thecitadel.nostr1.com', 'wss://nos.lol',
'wss://orly-relay.imwald.eu',
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://relay.nostr.watch' 'wss://relay.nostr.watch',
'wss://relay.primal.net',
'wss://relaypag.es',
'wss://thecitadel.nostr1.com'
] ]
const INTERVAL_MS = Number(process.env.INTERVAL_MS) || 3600000 // 1 hour /** Default 15 minutes; kind 10166 `frequency` tag uses the same interval in seconds. */
const INTERVAL_MS = Number(process.env.INTERVAL_MS) || 900000
const MAX_RELAYS_TO_MONITOR = Math.min(
2000,
Math.max(1, Number(process.env.MAX_RELAYS_TO_MONITOR) || 500)
)
function log (msg, data = {}) { function log (msg, data = {}) {
const ts = new Date().toISOString() const ts = new Date().toISOString()
@ -95,11 +146,12 @@ function build30166 (relayUrl, nip11, sk) {
} }
function build10166 (sk) { function build10166 (sk) {
const freqSec = Math.max(60, Math.round(INTERVAL_MS / 1000))
const draft = { const draft = {
kind: RELAY_MONITOR_ANNOUNCEMENT_KIND, kind: RELAY_MONITOR_ANNOUNCEMENT_KIND,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
content: '', content: '',
tags: [['frequency', '3600'], ['c', 'nip11'], ['c', 'ws']] tags: [['frequency', String(freqSec)], ['c', 'nip11'], ['c', 'ws']]
} }
return finalizeEvent(draft, sk) return finalizeEvent(draft, sk)
} }
@ -110,6 +162,142 @@ function parseListEnv (envVar, defaultList) {
return raw.split(',').map(s => s.trim()).filter(Boolean) return raw.split(',').map(s => s.trim()).filter(Boolean)
} }
/**
* REQ kind 10002 from `authorPubkey` on first relay that returns events; return deduped wss URLs from `r` tags.
*/
async function fetchRelayUrlsFromKind10002 (authorPubkey, queryRelayUrls) {
const pk = (authorPubkey || '').trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) {
log('Invalid pubkey for kind 10002 fetch (expected 64 hex chars)')
return []
}
const subId = 'nip66rl' + Math.random().toString(36).slice(2, 10)
const filter = { kinds: [10002], authors: [pk], limit: 30 }
for (const relayUrl of queryRelayUrls) {
let ws
try {
ws = new WebSocket(relayUrl, { handshakeTimeout: 12000 })
await new Promise((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
setTimeout(() => reject(new Error('open timeout')), 15000)
})
ws.send(JSON.stringify(['REQ', subId, filter]))
const events = await new Promise((resolve) => {
const acc = []
const t = setTimeout(() => {
cleanup()
resolve(acc)
}, 20000)
function cleanup () {
clearTimeout(t)
ws.removeListener('message', onMessage)
}
function onMessage (data) {
let msg
try {
msg = JSON.parse(data.toString())
} catch {
return
}
if (msg[0] === 'EVENT' && msg[1] === subId && msg[2]) acc.push(msg[2])
if (msg[0] === 'EOSE' && msg[1] === subId) {
cleanup()
resolve(acc)
}
}
ws.on('message', onMessage)
})
try {
ws.close()
} catch (_) {}
if (!events.length) continue
events.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
const ev = events[0]
const urls = new Set()
for (const t of ev.tags || []) {
if (t[0] !== 'r' || !t[1] || typeof t[1] !== 'string') continue
const u = t[1].trim()
if (u.startsWith('wss://') || u.startsWith('ws://')) urls.add(normalizeRelayUrl(u))
}
const list = [...urls]
log('Fetched kind 10002 relay list', { relay: relayUrl, author: pk.slice(0, 12), count: list.length })
return list
} catch (err) {
log('Kind 10002 fetch relay error', { relay: relayUrl, err: err.message })
try {
ws?.close()
} catch (_) {}
}
}
log('No kind 10002 found for author on query relays', { author: pk.slice(0, 12) })
return []
}
/** Concatenate lists, normalize, dedupe by URL string; order preserved (first list wins position). */
function mergeRelayUrlLists (...lists) {
const seen = new Set()
const out = []
for (const list of lists) {
for (const raw of list) {
if (!raw || typeof raw !== 'string') continue
const n = normalizeRelayUrl(raw.trim())
if (!n.startsWith('wss://') && !n.startsWith('ws://')) continue
if (seen.has(n)) continue
seen.add(n)
out.push(n)
}
}
return out
}
/**
* Resolve monitor URL list for this run.
*/
async function resolveRelaysToMonitor (sk, publishRelays) {
const rawEnv = process.env.RELAYS_TO_MONITOR
if (rawEnv && typeof rawEnv === 'string' && rawEnv.trim()) {
const list = rawEnv.split(',').map(s => normalizeRelayUrl(s.trim())).filter(Boolean)
log('Using RELAYS_TO_MONITOR override', { count: list.length })
return list.slice(0, MAX_RELAYS_TO_MONITOR)
}
const defaults = DEFAULT_RELAYS_TO_MONITOR.map((u) => normalizeRelayUrl(u))
const skip10002 =
process.env.RELAY_LIST_SKIP_KIND10002 === '1' ||
process.env.RELAY_LIST_SKIP_KIND10002 === 'true' ||
process.env.RELAY_LIST_SKIP_KIND10002 === 'yes'
if (skip10002) {
log('RELAY_LIST_SKIP_KIND10002 set; using default relay list only', { count: defaults.length })
return defaults.slice(0, MAX_RELAYS_TO_MONITOR)
}
const monitorPubkey = getPublicKey(sk)
const from10002 = await fetchRelayUrlsFromKind10002(monitorPubkey, publishRelays)
if (from10002.length === 0) {
log('No kind 10002 relays merged; using default list only', {
monitorPubkey: monitorPubkey.slice(0, 12),
defaultCount: defaults.length
})
return defaults.slice(0, MAX_RELAYS_TO_MONITOR)
}
const merged = mergeRelayUrlLists(defaults, from10002)
log('Merged default relays with monitor kind 10002', {
monitorPubkey: monitorPubkey.slice(0, 12),
defaultCount: defaults.length,
kind10002Count: from10002.length,
mergedCount: merged.length
})
return merged.slice(0, MAX_RELAYS_TO_MONITOR)
}
async function publishEvent (relayUrls, event) { async function publishEvent (relayUrls, event) {
const msg = JSON.stringify(['EVENT', event]) const msg = JSON.stringify(['EVENT', event])
let ok = 0 let ok = 0
@ -157,9 +345,13 @@ async function run10166 (sk, publishRelays) {
} }
async function run30166Round (sk, relaysToMonitor, publishRelays) { async function run30166Round (sk, relaysToMonitor, publishRelays) {
log('30166 round start', { relayCount: relaysToMonitor.length })
for (const relayUrl of relaysToMonitor) { for (const relayUrl of relaysToMonitor) {
const nip11 = await fetchNip11(relayUrl) const nip11 = await fetchNip11(relayUrl)
if (!nip11) continue if (!nip11) {
log('Skipping relay (no NIP-11)', { url: relayUrl })
continue
}
const event = build30166(relayUrl, nip11, sk) const event = build30166(relayUrl, nip11, sk)
const count = await publishEvent(publishRelays, event) const count = await publishEvent(publishRelays, event)
log('Published 30166', { url: relayUrl, successCount: count }) log('Published 30166', { url: relayUrl, successCount: count })
@ -174,12 +366,14 @@ async function main () {
} }
log('NIP-66 monitor cron started (nsec configured)') log('NIP-66 monitor cron started (nsec configured)')
const relaysToMonitor = parseListEnv('RELAYS_TO_MONITOR', DEFAULT_RELAYS_TO_MONITOR)
const publishRelays = parseListEnv('PUBLISH_RELAYS', DEFAULT_PUBLISH_RELAYS) const publishRelays = parseListEnv('PUBLISH_RELAYS', DEFAULT_PUBLISH_RELAYS)
await run10166(sk, publishRelays) await run10166(sk, publishRelays)
const run = () => run30166Round(sk, relaysToMonitor, publishRelays) const run = async () => {
const relaysToMonitor = await resolveRelaysToMonitor(sk, publishRelays)
await run30166Round(sk, relaysToMonitor, publishRelays)
}
await run() await run()
setInterval(run, INTERVAL_MS) setInterval(run, INTERVAL_MS)
} }

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "18.0.1", "version": "18.0.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "18.0.1", "version": "18.0.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "18.0.1", "version": "18.0.3",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

44
src/components/ui/dropdown-menu.tsx

@ -5,6 +5,17 @@ import { Check, ChevronDown, ChevronRight, ChevronUp, Circle } from 'lucide-reac
import { DialogContext } from '@/components/ui/dialog' import { DialogContext } from '@/components/ui/dialog'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** Radix `MenuSubContentProps` omits `side` / `align`; Popper still accepts them at runtime. */
type DropdownMenuSubContentPositionProps = Partial<
Pick<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>, 'side' | 'align'>
>
const RadixSubContent = DropdownMenuPrimitive.SubContent as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> &
DropdownMenuSubContentPositionProps &
React.RefAttributes<HTMLDivElement>
>
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
@ -40,16 +51,32 @@ DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayNam
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> &
showScrollButtons?: boolean DropdownMenuSubContentPositionProps & {
} showScrollButtons?: boolean
>(({ className, showScrollButtons = true, ...props }, ref) => { }
>(({ className, showScrollButtons = true, side: sideProp, align: alignProp, ...props }, ref) => {
const [canScrollUp, setCanScrollUp] = React.useState(false) const [canScrollUp, setCanScrollUp] = React.useState(false)
const [canScrollDown, setCanScrollDown] = React.useState(false) const [canScrollDown, setCanScrollDown] = React.useState(false)
const contentRef = React.useRef<HTMLDivElement>(null) const contentRef = React.useRef<HTMLDivElement>(null)
const scrollAreaRef = React.useRef<HTMLDivElement>(null) const scrollAreaRef = React.useRef<HTMLDivElement>(null)
const lastScrollStateRef = React.useRef({ canScrollUp: false, canScrollDown: false }) const lastScrollStateRef = React.useRef({ canScrollUp: false, canScrollDown: false })
/** Submenus default to the right; on narrow viewports open below so they stay on-screen (e.g. Spells). */
const [submenuBelow, setSubmenuBelow] = React.useState(
() => typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches
)
React.useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)')
const update = () => setSubmenuBelow(mq.matches)
update()
mq.addEventListener('change', update)
return () => mq.removeEventListener('change', update)
}, [])
const side = sideProp ?? (submenuBelow ? 'bottom' : 'right')
const align = alignProp ?? (submenuBelow ? 'start' : undefined)
React.useImperativeHandle(ref, () => contentRef.current!) React.useImperativeHandle(ref, () => contentRef.current!)
const checkScrollability = React.useCallback(() => { const checkScrollability = React.useCallback(() => {
@ -84,10 +111,13 @@ const DropdownMenuSubContent = React.forwardRef<
const inDialog = React.useContext(DialogContext) const inDialog = React.useContext(DialogContext)
return ( return (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent <RadixSubContent
ref={contentRef} ref={contentRef}
side={side}
align={align}
className={cn( className={cn(
'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
submenuBelow && 'max-w-[min(100vw-1.5rem,24rem)]',
inDialog ? 'z-[210]' : 'z-[100]' inDialog ? 'z-[210]' : 'z-[100]'
)} )}
onAnimationEnd={() => { onAnimationEnd={() => {
@ -95,7 +125,7 @@ const DropdownMenuSubContent = React.forwardRef<
checkScrollability() checkScrollability()
} }
}} }}
collisionPadding={10} collisionPadding={16}
{...props} {...props}
> >
{showScrollButtons && canScrollUp && ( {showScrollButtons && canScrollUp && (
@ -131,7 +161,7 @@ const DropdownMenuSubContent = React.forwardRef<
</button> </button>
</div> </div>
)} )}
</DropdownMenuPrimitive.SubContent> </RadixSubContent>
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) )
}) })

Loading…
Cancel
Save