You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
102 lines
3.5 KiB
102 lines
3.5 KiB
import logger from '@/lib/logger' |
|
import { notifyRelayNip42Accepted, notifyRelayNip42Rejected } from '@/lib/relay-auth-feedback' |
|
import type { AbstractRelay } from 'nostr-tools/abstract-relay' |
|
import type { EventTemplate, VerifiedEvent } from 'nostr-tools' |
|
|
|
type EventPubWaiter = { |
|
resolve: (v: unknown) => void |
|
reject: (e: unknown) => void |
|
timeout: ReturnType<typeof setTimeout> |
|
} |
|
|
|
/** Duck-type nostr-tools internals (class typings mark several fields private). */ |
|
type RelayInternals = { |
|
url: string |
|
connectionPromise?: Promise<unknown> |
|
openEventPublishes: Map<string, EventPubWaiter> |
|
authPromise?: Promise<string> |
|
} |
|
|
|
const patchedConstructors = new WeakSet<Function>() |
|
|
|
function asRelayInternals(relay: AbstractRelay): RelayInternals { |
|
return relay as unknown as RelayInternals |
|
} |
|
|
|
function abortPendingAuthForDeadSocket(relay: RelayInternals, message: string) { |
|
const i = message.indexOf('{') |
|
const j = message.lastIndexOf('}') |
|
if (i === -1 || j <= i) return |
|
let parsed: { id?: string } |
|
try { |
|
parsed = JSON.parse(message.slice(i, j + 1)) as { id?: string } |
|
} catch { |
|
return |
|
} |
|
const id = parsed.id |
|
if (!id) return |
|
const ep = relay.openEventPublishes.get(id) |
|
if (!ep) { |
|
relay.authPromise = undefined |
|
return |
|
} |
|
clearTimeout(ep.timeout) |
|
relay.openEventPublishes.delete(id) |
|
ep.reject(new Error('relay connection closed before AUTH could be sent')) |
|
relay.authPromise = undefined |
|
} |
|
|
|
/** |
|
* `nostr-tools` main `SimplePool` bundle embeds its own `AbstractRelay` class; it is **not** the same |
|
* object as `nostr-tools/abstract-relay`. Patching only the latter never affected pool connections, so |
|
* NIP-42 toast/feedback never ran. Call this once per relay **class** using the first instance from |
|
* `pool.ensureRelay` (same constructor for all pool relays). |
|
*/ |
|
export function patchPoolRelayAuthRaceAndFeedback(relay: object): void { |
|
const ctor = (relay as { constructor: Function }).constructor |
|
if (patchedConstructors.has(ctor)) return |
|
patchedConstructors.add(ctor) |
|
|
|
const proto = ctor.prototype as AbstractRelay |
|
const origSend = proto.send |
|
const origAuth = proto.auth |
|
|
|
proto.send = function (this: AbstractRelay, message: string) { |
|
const r = asRelayInternals(this) |
|
if (!r.connectionPromise && typeof message === 'string' && message.startsWith('["AUTH"')) { |
|
abortPendingAuthForDeadSocket(r, message) |
|
logger.debug('[RelayOp] Dropped AUTH (socket already closed; connect timeout vs signing race)', { |
|
url: r.url |
|
}) |
|
return Promise.resolve() |
|
} |
|
return origSend.call(this, message) as Promise<void> |
|
} |
|
|
|
proto.auth = function ( |
|
this: AbstractRelay, |
|
signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent> |
|
) { |
|
const r = asRelayInternals(this) |
|
const url = r.url |
|
return (origAuth.call(this, signAuthEvent) as Promise<string>) |
|
.then((okReason) => { |
|
notifyRelayNip42Accepted(url, typeof okReason === 'string' ? okReason : undefined) |
|
return okReason |
|
}) |
|
.catch((err: Error) => { |
|
const msg = err?.message ?? '' |
|
const benignRace = |
|
err?.name === 'SendingOnClosedConnection' || |
|
msg.includes('relay connection closed before AUTH') || |
|
/relay connection closed/i.test(msg) |
|
if (benignRace) { |
|
logger.debug('[RelayOp] Relay AUTH aborted (benign race)', { url: r.url, detail: msg }) |
|
r.authPromise = undefined |
|
return '' |
|
} |
|
notifyRelayNip42Rejected(url, msg) |
|
throw err |
|
}) |
|
} |
|
}
|
|
|