11 changed files with 938 additions and 127 deletions
@ -0,0 +1,363 @@
@@ -0,0 +1,363 @@
|
||||
<script lang='ts'> |
||||
import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag } from '$lib/utils/event_input_utils'; |
||||
import { get } from 'svelte/store'; |
||||
import { ndkInstance, activePubkey } from '$lib/ndk'; |
||||
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; |
||||
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||
import { standardRelays } from '$lib/consts'; |
||||
|
||||
let kind = $state<number>(30023); |
||||
let tags = $state<[string, string][]>([]); |
||||
let content = $state(''); |
||||
let createdAt = $state<number>(Math.floor(Date.now() / 1000)); |
||||
let loading = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let success = $state<string | null>(null); |
||||
let publishedRelays = $state<string[]>([]); |
||||
|
||||
let pubkey = $state<string | null>(null); |
||||
let title = $state(''); |
||||
let dTag = $state(''); |
||||
let titleManuallyEdited = $state(false); |
||||
let dTagManuallyEdited = $state(false); |
||||
let dTagError = $state(''); |
||||
let lastPublishedEventId = $state<string | null>(null); |
||||
$effect(() => { |
||||
pubkey = get(activePubkey); |
||||
}); |
||||
|
||||
/** |
||||
* Extracts the first Markdown/AsciiDoc header as the title. |
||||
*/ |
||||
function extractTitleFromContent(content: string): string { |
||||
// Match Markdown (# Title) or AsciiDoc (= Title) headers |
||||
const match = content.match(/^(#|=)\s*(.+)$/m); |
||||
return match ? match[2].trim() : ''; |
||||
} |
||||
|
||||
function handleContentInput(e: Event) { |
||||
content = (e.target as HTMLTextAreaElement).value; |
||||
if (!titleManuallyEdited) { |
||||
const extracted = extractTitleFromContent(content); |
||||
title = extracted; |
||||
} |
||||
} |
||||
|
||||
function handleTitleInput(e: Event) { |
||||
title = (e.target as HTMLInputElement).value; |
||||
titleManuallyEdited = true; |
||||
} |
||||
|
||||
function handleDTagInput(e: Event) { |
||||
dTag = (e.target as HTMLInputElement).value; |
||||
dTagManuallyEdited = true; |
||||
} |
||||
|
||||
$effect(() => { |
||||
if (!dTagManuallyEdited) { |
||||
dTag = titleToDTag(title); |
||||
} |
||||
}); |
||||
|
||||
function updateTag(index: number, key: string, value: string): void { |
||||
tags = tags.map((t, i) => i === index ? [key, value] : t); |
||||
} |
||||
function addTag(): void { |
||||
tags = [...tags, ['', '']]; |
||||
} |
||||
function removeTag(index: number): void { |
||||
tags = tags.filter((_, i) => i !== index); |
||||
} |
||||
|
||||
function isValidKind(kind: number | string): boolean { |
||||
const n = Number(kind); |
||||
return Number.isInteger(n) && n >= 0 && n <= 65535; |
||||
} |
||||
|
||||
function validate(): { valid: boolean; reason?: string } { |
||||
if (!pubkey) return { valid: false, reason: 'Not logged in.' }; |
||||
if (!content.trim()) return { valid: false, reason: 'Content required.' }; |
||||
if (kind === 30023) { |
||||
const v = validateNotAsciidoc(content); |
||||
if (!v.valid) return v; |
||||
} |
||||
if (kind === 30040 || kind === 30041 || kind === 30818) { |
||||
const v = validateAsciiDoc(content); |
||||
if (!v.valid) return v; |
||||
} |
||||
return { valid: true }; |
||||
} |
||||
|
||||
function handleSubmit(e: Event) { |
||||
e.preventDefault(); |
||||
dTagError = ''; |
||||
if (!dTag || dTag.trim() === '') { |
||||
dTagError = 'A d-tag is required.'; |
||||
return; |
||||
} |
||||
handlePublish(); |
||||
} |
||||
|
||||
async function handlePublish(): Promise<void> { |
||||
error = null; |
||||
success = null; |
||||
publishedRelays = []; |
||||
loading = true; |
||||
createdAt = Math.floor(Date.now() / 1000); |
||||
|
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
if (!ndk || !pubkey) { |
||||
error = 'NDK or pubkey missing.'; |
||||
loading = false; |
||||
return; |
||||
} |
||||
|
||||
if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) { |
||||
error = 'Invalid public key: must be a 64-character hex string.'; |
||||
loading = false; |
||||
return; |
||||
} |
||||
|
||||
// Validate before proceeding |
||||
const validation = validate(); |
||||
if (!validation.valid) { |
||||
error = validation.reason || 'Validation failed.'; |
||||
loading = false; |
||||
return; |
||||
} |
||||
|
||||
const baseEvent = { pubkey, created_at: createdAt }; |
||||
let events: NDKEvent[] = []; |
||||
|
||||
if (kind === 30040) { |
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
events = [indexEvent, ...sectionEvents]; |
||||
} else { |
||||
let eventTags = [...tags]; |
||||
|
||||
// Ensure d-tag exists and has a value for addressable events |
||||
if (requiresDTag(kind)) { |
||||
const dTagIndex = eventTags.findIndex(([k]) => k === 'd'); |
||||
const existingDTag = dTagIndex >= 0 ? eventTags[dTagIndex][1] : ''; |
||||
const generatedDTag = getDTagForEvent(kind, content, existingDTag); |
||||
|
||||
if (generatedDTag) { |
||||
if (dTagIndex >= 0) { |
||||
// Update existing d-tag |
||||
eventTags[dTagIndex] = ['d', generatedDTag]; |
||||
} else { |
||||
// Add new d-tag |
||||
eventTags = [...eventTags, ['d', generatedDTag]]; |
||||
} |
||||
} |
||||
} |
||||
|
||||
const title = getTitleTagForEvent(kind, content); |
||||
if (title) { |
||||
eventTags = [...eventTags, ['title', title]]; |
||||
} |
||||
|
||||
// Create event with proper serialization |
||||
const eventData = { |
||||
kind, |
||||
content, |
||||
tags: eventTags, |
||||
pubkey, |
||||
created_at: createdAt, |
||||
}; |
||||
|
||||
events = [new NDKEventClass(ndk, eventData)]; |
||||
} |
||||
|
||||
let atLeastOne = false; |
||||
let relaysPublished: string[] = []; |
||||
|
||||
for (const event of events) { |
||||
try { |
||||
// Always sign with a plain object if window.nostr is available |
||||
// Create a completely plain object to avoid proxy cloning issues |
||||
const plainEvent = { |
||||
kind: Number(event.kind), |
||||
pubkey: String(event.pubkey), |
||||
created_at: Number(event.created_at ?? Math.floor(Date.now() / 1000)), |
||||
tags: event.tags.map(tag => [String(tag[0]), String(tag[1])]), |
||||
content: String(event.content), |
||||
}; |
||||
if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) { |
||||
const signed = await window.nostr.signEvent(plainEvent); |
||||
event.sig = signed.sig; |
||||
if ('id' in signed) { |
||||
event.id = signed.id as string; |
||||
} |
||||
} else { |
||||
await event.sign(); |
||||
} |
||||
|
||||
// Use direct WebSocket publishing like CommentBox does |
||||
const signedEvent = { |
||||
...plainEvent, |
||||
id: event.id, |
||||
sig: event.sig, |
||||
}; |
||||
|
||||
// Try to publish to relays directly |
||||
const relays = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', ...standardRelays]; |
||||
let published = false; |
||||
|
||||
for (const relayUrl of relays) { |
||||
try { |
||||
const ws = new WebSocket(relayUrl); |
||||
await new Promise<void>((resolve, reject) => { |
||||
const timeout = setTimeout(() => { |
||||
ws.close(); |
||||
reject(new Error("Timeout")); |
||||
}, 5000); |
||||
|
||||
ws.onopen = () => { |
||||
ws.send(JSON.stringify(["EVENT", signedEvent])); |
||||
}; |
||||
|
||||
ws.onmessage = (e) => { |
||||
const [type, id, ok, message] = JSON.parse(e.data); |
||||
if (type === "OK" && id === signedEvent.id) { |
||||
clearTimeout(timeout); |
||||
if (ok) { |
||||
published = true; |
||||
relaysPublished.push(relayUrl); |
||||
ws.close(); |
||||
resolve(); |
||||
} else { |
||||
ws.close(); |
||||
reject(new Error(message)); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
ws.onerror = () => { |
||||
clearTimeout(timeout); |
||||
ws.close(); |
||||
reject(new Error("WebSocket error")); |
||||
}; |
||||
}); |
||||
if (published) break; |
||||
} catch (e) { |
||||
console.error(`Failed to publish to ${relayUrl}:`, e); |
||||
} |
||||
} |
||||
|
||||
if (published) { |
||||
atLeastOne = true; |
||||
lastPublishedEventId = event.id; |
||||
} |
||||
} catch (signError) { |
||||
console.error('Error signing/publishing event:', signError); |
||||
error = `Failed to sign event: ${signError instanceof Error ? signError.message : 'Unknown error'}`; |
||||
loading = false; |
||||
return; |
||||
} |
||||
} |
||||
|
||||
loading = false; |
||||
if (atLeastOne) { |
||||
publishedRelays = relaysPublished; |
||||
success = `Published to ${relaysPublished.length} relay(s).`; |
||||
} else { |
||||
error = 'Failed to publish to any relay.'; |
||||
} |
||||
} catch (err) { |
||||
console.error('Error in handlePublish:', err); |
||||
error = `Publishing failed: ${err instanceof Error ? err.message : 'Unknown error'}`; |
||||
loading = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
{#if pubkey} |
||||
<div class='w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg'> |
||||
<h2 class='text-xl font-bold mb-4'>Publish Nostr Event</h2> |
||||
<form class='space-y-4' onsubmit={handleSubmit}> |
||||
<div> |
||||
<label class='block font-medium mb-1' for='event-kind'>Kind</label> |
||||
<input id='event-kind' type='text' class='input input-bordered w-full' bind:value={kind} required /> |
||||
{#if !isValidKind(kind)} |
||||
<div class="text-red-600 text-sm mt-1"> |
||||
Kind must be an integer between 0 and 65535 (NIP-01). |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
<div> |
||||
<label class='block font-medium mb-1' for='tags-container'>Tags</label> |
||||
<div id='tags-container' class='space-y-2'> |
||||
{#each tags as [key, value], i} |
||||
<div class='flex gap-2'> |
||||
<input type='text' class='input input-bordered flex-1' placeholder='tag' bind:value={tags[i][0]} oninput={e => updateTag(i, (e.target as HTMLInputElement).value, tags[i][1])} /> |
||||
<input type='text' class='input input-bordered flex-1' placeholder='value' bind:value={tags[i][1]} oninput={e => updateTag(i, tags[i][0], (e.target as HTMLInputElement).value)} /> |
||||
<button type='button' class='btn btn-error btn-sm' onclick={() => removeTag(i)} disabled={tags.length === 1}>×</button> |
||||
</div> |
||||
{/each} |
||||
<button type='button' class='btn btn-secondary btn-sm' onclick={addTag}>Add Tag</button> |
||||
</div> |
||||
</div> |
||||
<div> |
||||
<label class='block font-medium mb-1' for='event-content'>Content</label> |
||||
<textarea |
||||
id='event-content' |
||||
bind:value={content} |
||||
oninput={handleContentInput} |
||||
placeholder='Content (start with a header for the title)' |
||||
class='textarea textarea-bordered w-full h-40' |
||||
required |
||||
></textarea> |
||||
</div> |
||||
<div> |
||||
<label class='block font-medium mb-1' for='event-title'>Title</label> |
||||
<input |
||||
type='text' |
||||
id='event-title' |
||||
bind:value={title} |
||||
oninput={handleTitleInput} |
||||
placeholder='Title (auto-filled from header)' |
||||
class='input input-bordered w-full' |
||||
/> |
||||
</div> |
||||
<div> |
||||
<label class='block font-medium mb-1' for='event-d-tag'>d-tag</label> |
||||
<input |
||||
type='text' |
||||
id='event-d-tag' |
||||
bind:value={dTag} |
||||
oninput={handleDTagInput} |
||||
placeholder='d-tag (auto-generated from title)' |
||||
class='input input-bordered w-full' |
||||
required |
||||
/> |
||||
{#if dTagError} |
||||
<div class='text-red-600 text-sm mt-1'>{dTagError}</div> |
||||
{/if} |
||||
</div> |
||||
<button type='submit' class='btn btn-primary' disabled={loading}>Publish</button> |
||||
{#if loading} |
||||
<span class='ml-2 text-gray-500'>Publishing...</span> |
||||
{/if} |
||||
{#if error} |
||||
<div class='mt-2 text-red-600'>{error}</div> |
||||
{/if} |
||||
{#if success} |
||||
<div class='mt-2 text-green-600'>{success}</div> |
||||
<div class='text-xs text-gray-500'>Relays: {publishedRelays.join(', ')}</div> |
||||
{#if lastPublishedEventId} |
||||
<div class='mt-2 text-green-700'> |
||||
Event ID: <span class='font-mono'>{lastPublishedEventId}</span> |
||||
<a |
||||
href={'/events?id=' + lastPublishedEventId} |
||||
class='text-primary-600 dark:text-primary-500 hover:underline ml-2' |
||||
> |
||||
View your event |
||||
</a> |
||||
</div> |
||||
{/if} |
||||
{/if} |
||||
</form> |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
import { writable, derived } from 'svelte/store'; |
||||
|
||||
/** |
||||
* Stores the user's public key if logged in, or null otherwise. |
||||
*/ |
||||
export const userPubkey = writable<string | null>(null); |
||||
|
||||
/** |
||||
* Derived store indicating if the user is logged in. |
||||
*/ |
||||
export const isLoggedIn = derived(userPubkey, ($userPubkey) => !!$userPubkey);
|
||||
@ -0,0 +1,219 @@
@@ -0,0 +1,219 @@
|
||||
import type { NDKEvent } from './nostrUtils'; |
||||
import { get } from 'svelte/store'; |
||||
import { ndkInstance } from '$lib/ndk'; |
||||
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; |
||||
|
||||
// =========================
|
||||
// Validation
|
||||
// =========================
|
||||
|
||||
/** |
||||
* Returns true if the event kind requires a d-tag (kinds 30000-39999). |
||||
*/ |
||||
export function requiresDTag(kind: number): boolean { |
||||
return kind >= 30000 && kind <= 39999; |
||||
} |
||||
|
||||
/** |
||||
* Returns true if the tags array contains at least one d-tag with a non-empty value. |
||||
*/ |
||||
export function hasDTag(tags: [string, string][]): boolean { |
||||
return tags.some(([k, v]) => k === 'd' && v && v.trim() !== ''); |
||||
} |
||||
|
||||
/** |
||||
* Returns true if the content contains AsciiDoc headers (lines starting with '=' or '=='). |
||||
*/ |
||||
function containsAsciiDocHeaders(content: string): boolean { |
||||
return /^={1,}\s+/m.test(content); |
||||
} |
||||
|
||||
/** |
||||
* Validates that content does NOT contain AsciiDoc headers (for kind 30023). |
||||
* Returns { valid, reason }. |
||||
*/ |
||||
export function validateNotAsciidoc(content: string): { valid: boolean; reason?: string } { |
||||
if (containsAsciiDocHeaders(content)) { |
||||
return { |
||||
valid: false, |
||||
reason: 'Kind 30023 must not contain AsciiDoc headers (lines starting with = or ==).', |
||||
}; |
||||
} |
||||
return { valid: true }; |
||||
} |
||||
|
||||
/** |
||||
* Validates AsciiDoc content. Must start with '=' and contain at least one '==' section header. |
||||
* Returns { valid, reason }. |
||||
*/ |
||||
export function validateAsciiDoc(content: string): { valid: boolean; reason?: string } { |
||||
if (!content.trim().startsWith('=')) { |
||||
return { valid: false, reason: 'AsciiDoc must start with a document title ("=").' }; |
||||
} |
||||
if (!/^==\s+/m.test(content)) { |
||||
return { valid: false, reason: 'AsciiDoc must contain at least one section header ("==").' }; |
||||
} |
||||
return { valid: true }; |
||||
} |
||||
|
||||
// =========================
|
||||
// Extraction & Normalization
|
||||
// =========================
|
||||
|
||||
/** |
||||
* Normalize a string for use as a d-tag: lowercase, hyphens, alphanumeric only. |
||||
*/ |
||||
function normalizeDTagValue(header: string): string { |
||||
return header |
||||
.toLowerCase() |
||||
.replace(/[^\p{L}\p{N}]+/gu, '-') |
||||
.replace(/^-+|-+$/g, ''); |
||||
} |
||||
|
||||
/** |
||||
* Converts a title string to a valid d-tag (lowercase, hyphens, no punctuation). |
||||
*/ |
||||
export function titleToDTag(title: string): string { |
||||
return title |
||||
.toLowerCase() |
||||
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
|
||||
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
|
||||
} |
||||
|
||||
/** |
||||
* Extracts the first AsciiDoc document header (line starting with '= '). |
||||
*/ |
||||
function extractAsciiDocDocumentHeader(content: string): string | null { |
||||
const match = content.match(/^=\s+(.+)$/m); |
||||
return match ? match[1].trim() : null; |
||||
} |
||||
|
||||
/** |
||||
* Extracts all section headers (lines starting with '== '). |
||||
*/ |
||||
function extractAsciiDocSectionHeaders(content: string): string[] { |
||||
return Array.from(content.matchAll(/^==\s+(.+)$/gm)).map(m => m[1].trim()); |
||||
} |
||||
|
||||
/** |
||||
* Extracts the topmost Markdown # header (line starting with '# '). |
||||
*/ |
||||
function extractMarkdownTopHeader(content: string): string | null { |
||||
const match = content.match(/^#\s+(.+)$/m); |
||||
return match ? match[1].trim() : null; |
||||
} |
||||
|
||||
/** |
||||
* Splits AsciiDoc content into sections at each '==' header. Returns array of section strings. |
||||
*/ |
||||
function splitAsciiDocSections(content: string): string[] { |
||||
const lines = content.split(/\r?\n/); |
||||
const sections: string[] = []; |
||||
let current: string[] = []; |
||||
for (const line of lines) { |
||||
if (/^==\s+/.test(line) && current.length > 0) { |
||||
sections.push(current.join('\n').trim()); |
||||
current = []; |
||||
} |
||||
current.push(line); |
||||
} |
||||
if (current.length > 0) { |
||||
sections.push(current.join('\n').trim()); |
||||
} |
||||
return sections; |
||||
} |
||||
|
||||
// =========================
|
||||
// Event Construction
|
||||
// =========================
|
||||
|
||||
/** |
||||
* Returns the current NDK instance from the store. |
||||
*/ |
||||
function getNdk() { |
||||
return get(ndkInstance); |
||||
} |
||||
|
||||
/** |
||||
* Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section. |
||||
* Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header). |
||||
* The 30040 index event references all 30041s by their d-tag. |
||||
*/ |
||||
export function build30040EventSet( |
||||
content: string, |
||||
tags: [string, string][], |
||||
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number } |
||||
): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } { |
||||
const ndk = getNdk(); |
||||
const sections = splitAsciiDocSections(content); |
||||
const sectionHeaders = extractAsciiDocSectionHeaders(content); |
||||
const dTags = sectionHeaders.length === sections.length |
||||
? sectionHeaders.map(normalizeDTagValue) |
||||
: sections.map((_, i) => `section${i}`); |
||||
const sectionEvents: NDKEvent[] = sections.map((section, i) => { |
||||
const header = sectionHeaders[i] || `Section ${i + 1}`; |
||||
const dTag = dTags[i]; |
||||
return new NDKEventClass(ndk, { |
||||
kind: 30041, |
||||
content: section, |
||||
tags: [ |
||||
...tags, |
||||
['d', dTag], |
||||
['title', header], |
||||
], |
||||
pubkey: baseEvent.pubkey, |
||||
created_at: baseEvent.created_at, |
||||
}); |
||||
}); |
||||
const indexTags = [ |
||||
...tags, |
||||
...dTags.map(d => ['a', d] as [string, string]), |
||||
]; |
||||
const indexEvent: NDKEvent = new NDKEventClass(ndk, { |
||||
kind: 30040, |
||||
content: '', |
||||
tags: indexTags, |
||||
pubkey: baseEvent.pubkey, |
||||
created_at: baseEvent.created_at, |
||||
}); |
||||
return { indexEvent, sectionEvents }; |
||||
} |
||||
|
||||
/** |
||||
* Returns the appropriate title tag for a given event kind and content. |
||||
* - 30041, 30818: AsciiDoc document header (first '= ' line) |
||||
* - 30023: Markdown topmost '# ' header |
||||
*/ |
||||
export function getTitleTagForEvent(kind: number, content: string): string | null { |
||||
if (kind === 30041 || kind === 30818) { |
||||
return extractAsciiDocDocumentHeader(content); |
||||
} |
||||
if (kind === 30023) { |
||||
return extractMarkdownTopHeader(content); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* Returns the appropriate d-tag value for a given event kind and content. |
||||
* - 30023: Normalized markdown header |
||||
* - 30041, 30818: Normalized AsciiDoc document header |
||||
* - 30040: Uses existing d-tag or generates from content |
||||
*/ |
||||
export function getDTagForEvent(kind: number, content: string, existingDTag?: string): string | null { |
||||
if (existingDTag && existingDTag.trim() !== '') { |
||||
return existingDTag.trim(); |
||||
} |
||||
|
||||
if (kind === 30023) { |
||||
const title = extractMarkdownTopHeader(content); |
||||
return title ? normalizeDTagValue(title) : null; |
||||
} |
||||
|
||||
if (kind === 30041 || kind === 30818) { |
||||
const title = extractAsciiDocDocumentHeader(content); |
||||
return title ? normalizeDTagValue(title) : null; |
||||
} |
||||
|
||||
return null; |
||||
}
|
||||
@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
|
||||
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts'; |
||||
import NDK from '@nostr-dev-kit/ndk'; |
||||
|
||||
export interface RelayDiagnostic { |
||||
url: string; |
||||
connected: boolean; |
||||
requiresAuth: boolean; |
||||
error?: string; |
||||
responseTime?: number; |
||||
} |
||||
|
||||
/** |
||||
* Tests connection to a single relay |
||||
*/ |
||||
export async function testRelay(url: string): Promise<RelayDiagnostic> { |
||||
const startTime = Date.now(); |
||||
|
||||
return new Promise((resolve) => { |
||||
const ws = new WebSocket(url); |
||||
let resolved = false; |
||||
|
||||
const timeout = setTimeout(() => { |
||||
if (!resolved) { |
||||
resolved = true; |
||||
ws.close(); |
||||
resolve({ |
||||
url, |
||||
connected: false, |
||||
requiresAuth: false, |
||||
error: 'Connection timeout', |
||||
responseTime: Date.now() - startTime, |
||||
}); |
||||
} |
||||
}, 5000); |
||||
|
||||
ws.onopen = () => { |
||||
if (!resolved) { |
||||
resolved = true; |
||||
clearTimeout(timeout); |
||||
ws.close(); |
||||
resolve({ |
||||
url, |
||||
connected: true, |
||||
requiresAuth: false, |
||||
responseTime: Date.now() - startTime, |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
ws.onerror = () => { |
||||
if (!resolved) { |
||||
resolved = true; |
||||
clearTimeout(timeout); |
||||
resolve({ |
||||
url, |
||||
connected: false, |
||||
requiresAuth: false, |
||||
error: 'WebSocket error', |
||||
responseTime: Date.now() - startTime, |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
ws.onmessage = (event) => { |
||||
const data = JSON.parse(event.data); |
||||
if (data[0] === 'NOTICE' && data[1]?.includes('auth-required')) { |
||||
if (!resolved) { |
||||
resolved = true; |
||||
clearTimeout(timeout); |
||||
ws.close(); |
||||
resolve({ |
||||
url, |
||||
connected: true, |
||||
requiresAuth: true, |
||||
responseTime: Date.now() - startTime, |
||||
}); |
||||
} |
||||
} |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Tests all relays and returns diagnostic information |
||||
*/ |
||||
export async function testAllRelays(): Promise<RelayDiagnostic[]> { |
||||
const allRelays = [...new Set([...standardRelays, ...anonymousRelays, ...fallbackRelays])]; |
||||
|
||||
console.log('[RelayDiagnostics] Testing', allRelays.length, 'relays...'); |
||||
|
||||
const results = await Promise.allSettled( |
||||
allRelays.map(url => testRelay(url)) |
||||
); |
||||
|
||||
return results.map((result, index) => { |
||||
if (result.status === 'fulfilled') { |
||||
return result.value; |
||||
} else { |
||||
return { |
||||
url: allRelays[index], |
||||
connected: false, |
||||
requiresAuth: false, |
||||
error: 'Test failed', |
||||
}; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Gets working relays from diagnostic results |
||||
*/ |
||||
export function getWorkingRelays(diagnostics: RelayDiagnostic[]): string[] { |
||||
return diagnostics |
||||
.filter(d => d.connected) |
||||
.map(d => d.url); |
||||
} |
||||
|
||||
/** |
||||
* Logs relay diagnostic results to console |
||||
*/ |
||||
export function logRelayDiagnostics(diagnostics: RelayDiagnostic[]): void { |
||||
console.group('[RelayDiagnostics] Results'); |
||||
|
||||
const working = diagnostics.filter(d => d.connected); |
||||
const failed = diagnostics.filter(d => !d.connected); |
||||
|
||||
console.log(`✅ Working relays (${working.length}):`); |
||||
working.forEach(d => { |
||||
console.log(` - ${d.url}${d.requiresAuth ? ' (requires auth)' : ''}${d.responseTime ? ` (${d.responseTime}ms)` : ''}`); |
||||
}); |
||||
|
||||
if (failed.length > 0) { |
||||
console.log(`❌ Failed relays (${failed.length}):`); |
||||
failed.forEach(d => { |
||||
console.log(` - ${d.url}: ${d.error || 'Unknown error'}`); |
||||
}); |
||||
} |
||||
|
||||
console.groupEnd(); |
||||
}
|
||||
Loading…
Reference in new issue