refactor: remove wrapper classes, use replaceableEventCache directly

Major cleanup of caching layer - remove abstraction wrappers and use
the generic cache directly throughout the codebase.

Changes:
- Add convenience methods to ReplaceableEventCache:
  - getOutboxRelays() / getOutboxRelaysSync() for kind:10002
  - getInboxRelays() for kind:10002
  - getBlossomServers() / getBlossomServersSync() for kind:10063
  - normalizeRelays() helper

- Remove wrapper classes:
  - Delete src/services/relay-list-cache.ts (148 lines removed)
  - Delete src/services/blossom-server-cache.ts (86 lines removed)

- Update all imports and usages:
  - Services: loaders.ts, relay-selection.ts, hub.ts
  - Actions: delete-event.ts, publish-spell.ts
  - Components: ProfileViewer, ShareSpellbookDialog, ZapstoreApp renderers
  - Tests: relay-selection.test.ts, loaders.test.ts, publish-spell.test.ts

Benefits:
- Simpler architecture - one cache instead of three
- Less code duplication (234 lines removed)
- Single source of truth for all replaceable events
- Easier to maintain and extend

Deprecated interfaces (CachedRelayList, CachedBlossomServerList) kept
for backward compatibility with older database versions.
This commit is contained in:
Claude
2026-01-16 22:30:07 +00:00
parent b813f7bd2b
commit ee49c173b6
15 changed files with 120 additions and 259 deletions

View File

@@ -1,7 +1,7 @@
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import { EventFactory } from "applesauce-core/event-factory";
import { relayListCache } from "@/services/relay-list-cache";
import replaceableEventCache from "@/services/replaceable-event-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import { grimoireStateAtom } from "@/core/state";
@@ -31,7 +31,7 @@ export class DeleteEventAction {
// Get write relays from cache and state
const authorWriteRelays =
(await relayListCache.getOutboxRelays(account.pubkey)) || [];
(await replaceableEventCache.getOutboxRelays(account.pubkey)) || [];
const store = getDefaultStore();
const state = store.get(grimoireStateAtom);

View File

@@ -25,8 +25,8 @@ vi.mock("@/services/spell-storage", () => ({
markSpellPublished: vi.fn(),
}));
vi.mock("@/services/relay-list-cache", () => ({
relayListCache: {
vi.mock("@/services/replaceable-event-cache", () => ({
replaceableEventCache: {
getOutboxRelays: vi.fn().mockResolvedValue([]),
},
}));

View File

@@ -5,7 +5,7 @@ import { encodeSpell } from "@/lib/spell-conversion";
import { markSpellPublished } from "@/services/spell-storage";
import { EventFactory } from "applesauce-core/event-factory";
import { SpellEvent } from "@/types/spell";
import { relayListCache } from "@/services/relay-list-cache";
import replaceableEventCache from "@/services/replaceable-event-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import eventStore from "@/services/event-store";
@@ -57,7 +57,7 @@ export class PublishSpellAction {
if (!relays || relays.length === 0) {
const authorWriteRelays =
(await relayListCache.getOutboxRelays(account.pubkey)) || [];
(await replaceableEventCache.getOutboxRelays(account.pubkey)) || [];
relays = mergeRelaySets(
event.tags.find((t) => t[0] === "relays")?.slice(1) || [],

View File

@@ -27,12 +27,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useRelayState } from "@/hooks/useRelayState";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
import { addressLoader } from "@/services/loaders";
import { relayListCache } from "@/services/relay-list-cache";
import replaceableEventCache from "@/services/replaceable-event-cache";
import { useEffect, useState } from "react";
import type { Subscription } from "rxjs";
import { useGrimoire } from "@/core/state";
import { USER_SERVER_LIST_KIND, getServersFromEvent } from "@/services/blossom";
import blossomServerCache from "@/services/blossom-server-cache";
// blossomServerCache is now part of replaceableEventCache
export interface ProfileViewerProps {
pubkey: string;
@@ -59,15 +59,15 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
let subscription: Subscription | null = null;
if (!resolvedPubkey) return;
// Check if we have a valid cached relay list
relayListCache.has(resolvedPubkey).then(async (hasCached) => {
// Check if we have a valid cached relay list (kind:10002)
replaceableEventCache.has(resolvedPubkey, 10002).then(async (hasCached) => {
if (hasCached) {
console.debug(
`[ProfileViewer] Using cached relay list for ${resolvedPubkey.slice(0, 8)}`,
);
// Load cached event into EventStore so UI can display it
const cached = await relayListCache.get(resolvedPubkey);
const cached = await replaceableEventCache.get(resolvedPubkey, 10002);
if (cached?.event) {
eventStore.add(cached.event);
console.debug(
@@ -135,7 +135,7 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
}
// First, check cache for instant display
blossomServerCache.getServers(resolvedPubkey).then((cachedServers) => {
replaceableEventCache.getBlossomServers(resolvedPubkey).then((cachedServers) => {
if (cachedServers && cachedServers.length > 0) {
setBlossomServers(cachedServers);
}

View File

@@ -13,7 +13,7 @@ import { toast } from "sonner";
import { nip19 } from "nostr-tools";
import type { NostrEvent } from "@/types/nostr";
import type { ParsedSpellbook } from "@/types/spell";
import { relayListCache } from "@/services/relay-list-cache";
import replaceableEventCache from "@/services/replaceable-event-cache";
interface ShareSpellbookDialogProps {
open: boolean;
@@ -43,7 +43,9 @@ export function ShareSpellbookDialog({
let relays = event.tags.filter((t) => t[0] === "r").map((t) => t[1]);
if (relays.length === 0) {
const authorRelays = await relayListCache.getOutboxRelays(event.pubkey);
const authorRelays = await replaceableEventCache.getOutboxRelays(
event.pubkey,
);
if (authorRelays) {
relays = authorRelays;
}

View File

@@ -28,7 +28,7 @@ import {
FileDown,
} from "lucide-react";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { relayListCache } from "@/services/relay-list-cache";
import replaceableEventCache from "@/services/replaceable-event-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { useLiveTimeline } from "@/hooks/useLiveTimeline";
@@ -184,7 +184,9 @@ export function ZapstoreAppDetailRenderer({
}
// Add publisher's outbox relays
const outboxRelays = relayListCache.getOutboxRelaysSync(event.pubkey);
const outboxRelays = replaceableEventCache.getOutboxRelaysSync(
event.pubkey,
);
if (outboxRelays) {
for (const relay of outboxRelays.slice(0, 3)) {
relaySet.add(relay);

View File

@@ -16,7 +16,7 @@ import { useMemo } from "react";
import { useGrimoire } from "@/core/state";
import { FileDown } from "lucide-react";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { relayListCache } from "@/services/relay-list-cache";
import replaceableEventCache from "@/services/replaceable-event-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { useLiveTimeline } from "@/hooks/useLiveTimeline";
@@ -47,7 +47,9 @@ export function ZapstoreAppRenderer({ event }: BaseEventProps) {
}
// Add publisher's outbox relays
const outboxRelays = relayListCache.getOutboxRelaysSync(event.pubkey);
const outboxRelays = replaceableEventCache.getOutboxRelaysSync(
event.pubkey,
);
if (outboxRelays) {
for (const relay of outboxRelays.slice(0, 3)) {
relaySet.add(relay);

View File

@@ -1,86 +0,0 @@
/**
* Blossom Server Cache Service
*
* Wrapper around generic ReplaceableEventCache for BUD-03 blossom server lists (kind:10063).
* Provides convenient helpers for accessing blossom servers.
*
* Now uses the generic cache for storage - parsing happens on-demand.
*/
import { getServersFromEvent } from "./blossom";
import replaceableEventCache from "./replaceable-event-cache";
import type { IEventStore } from "applesauce-core/event-store";
const BLOSSOM_SERVER_LIST_KIND = 10063;
class BlossomServerCache {
/**
* Subscribe to EventStore to auto-cache kind:10063 events
* @deprecated - Now handled by generic ReplaceableEventCache
* Kept for backward compatibility with existing code
*/
subscribeToEventStore(_eventStore: IEventStore): void {
console.warn(
"[BlossomServerCache] subscribeToEventStore is deprecated - kind:10063 is now auto-cached by ReplaceableEventCache",
);
}
/**
* Unsubscribe from EventStore
* @deprecated - Now handled by generic ReplaceableEventCache
* Kept for backward compatibility with existing code
*/
unsubscribe(): void {
console.warn(
"[BlossomServerCache] unsubscribe is deprecated - managed by ReplaceableEventCache",
);
}
/**
* Get blossom servers from memory cache only (synchronous, fast)
* Used for real-time operations where async Dexie lookup would be too slow
* Returns null if not in memory cache
*/
getServersSync(pubkey: string): string[] | null {
const event = replaceableEventCache.getSync(
pubkey,
BLOSSOM_SERVER_LIST_KIND,
);
if (!event) return null;
// Parse on-demand
return getServersFromEvent(event);
}
/**
* Get blossom servers for a pubkey from cache
*/
async getServers(pubkey: string): Promise<string[] | null> {
const event = await replaceableEventCache.getEvent(
pubkey,
BLOSSOM_SERVER_LIST_KIND,
);
if (!event) return null;
// Parse on-demand
return getServersFromEvent(event);
}
/**
* Check if we have a valid cache entry for a pubkey
*/
async has(pubkey: string): Promise<boolean> {
return replaceableEventCache.has(pubkey, BLOSSOM_SERVER_LIST_KIND);
}
/**
* Invalidate (delete) cache entry for a pubkey
*/
async invalidate(pubkey: string): Promise<void> {
return replaceableEventCache.invalidate(pubkey, BLOSSOM_SERVER_LIST_KIND);
}
}
// Singleton instance
export const blossomServerCache = new BlossomServerCache();
export default blossomServerCache;

View File

@@ -2,7 +2,7 @@ import { ActionRunner } from "applesauce-actions";
import eventStore from "./event-store";
import { EventFactory } from "applesauce-core/event-factory";
import pool from "./relay-pool";
import { relayListCache } from "./relay-list-cache";
import replaceableEventCache from "./replaceable-event-cache";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import type { NostrEvent } from "nostr-tools/core";
import accountManager from "./accounts";
@@ -15,7 +15,7 @@ import accountManager from "./accounts";
*/
export async function publishEvent(event: NostrEvent): Promise<void> {
// Try to get author's outbox relays from EventStore (kind 10002)
let relays = await relayListCache.getOutboxRelays(event.pubkey);
let relays = await replaceableEventCache.getOutboxRelays(event.pubkey);
// Fallback to relays from the event itself (where it was seen)
if (!relays || relays.length === 0) {

View File

@@ -16,7 +16,7 @@ vi.mock("./event-store", () => ({
}));
vi.mock("./relay-list-cache", () => ({
relayListCache: {
replaceableEventCache: {
getOutboxRelaysSync: vi.fn(),
},
}));
@@ -38,7 +38,7 @@ vi.mock("applesauce-loaders/loaders", () => ({
}));
import eventStore from "./event-store";
import { relayListCache } from "./relay-list-cache";
import replaceableEventCache from "./replaceable-event-cache";
// Test helpers
function createMockEvent(overrides: Partial<NostrEvent> = {}): NostrEvent {
@@ -108,7 +108,7 @@ describe("eventLoader", () => {
describe("backward compatibility with string authorHint", () => {
it("should accept string pubkey as context", () => {
vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([
vi.mocked(replaceableEventCache.getOutboxRelaysSync).mockReturnValue([
"wss://author-relay.com/",
]);
@@ -123,7 +123,7 @@ describe("eventLoader", () => {
});
it("should use cached relays when authorHint provided", () => {
vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([
vi.mocked(replaceableEventCache.getOutboxRelaysSync).mockReturnValue([
"wss://cached1.com/",
"wss://cached2.com/",
"wss://cached3.com/",
@@ -195,7 +195,7 @@ describe("eventLoader", () => {
});
it("should extract author hint from p tags", () => {
vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([
vi.mocked(replaceableEventCache.getOutboxRelaysSync).mockReturnValue([
"wss://author-outbox.com/",
]);
@@ -214,7 +214,7 @@ describe("eventLoader", () => {
});
it("should combine all relay sources", () => {
vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([
vi.mocked(replaceableEventCache.getOutboxRelaysSync).mockReturnValue([
"wss://cached.com/",
]);
@@ -272,7 +272,7 @@ describe("eventLoader", () => {
});
it("should prioritize seen relays over cached relays", () => {
vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([
vi.mocked(replaceableEventCache.getOutboxRelaysSync).mockReturnValue([
"wss://cached.com/",
]);
@@ -292,7 +292,7 @@ describe("eventLoader", () => {
describe("deduplication", () => {
it("should deduplicate same relay from different sources", () => {
vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([
vi.mocked(replaceableEventCache.getOutboxRelaysSync).mockReturnValue([
"wss://duplicate.com/",
]);
@@ -364,7 +364,7 @@ describe("eventLoader", () => {
it("should use existing event author when event is in store", () => {
const existingEvent = createMockEvent({ pubkey: "existing-author" });
vi.mocked(eventStore.getEvent).mockReturnValue(existingEvent);
vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([
vi.mocked(replaceableEventCache.getOutboxRelaysSync).mockReturnValue([
"wss://existing-author-relay.com/",
]);
@@ -381,7 +381,7 @@ describe("eventLoader", () => {
it("should fall back to aggregators when no other relays available", () => {
vi.mocked(eventStore.getEvent).mockReturnValue(undefined);
vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([]);
vi.mocked(replaceableEventCache.getOutboxRelaysSync).mockReturnValue([]);
const event = createMockEvent({ tags: [] });
@@ -397,7 +397,7 @@ describe("eventLoader", () => {
});
it("should limit cached relays to 3", () => {
vi.mocked(relayListCache.getOutboxRelaysSync).mockReturnValue([
vi.mocked(replaceableEventCache.getOutboxRelaysSync).mockReturnValue([
"wss://cached1.com/",
"wss://cached2.com/",
"wss://cached3.com/",

View File

@@ -11,7 +11,7 @@ import { getEventPointerFromETag } from "applesauce-core/helpers/pointers";
import { getTagValue } from "applesauce-core/helpers/event";
import pool from "./relay-pool";
import eventStore from "./event-store";
import { relayListCache } from "./relay-list-cache";
import replaceableEventCache from "./replaceable-event-cache";
import type { NostrEvent } from "@/types/nostr";
/**
@@ -117,12 +117,13 @@ export function eventLoader(
const existingEvent = eventStore.getEvent(pointer.id);
if (existingEvent) {
cachedOutboxRelays =
relayListCache.getOutboxRelaysSync(existingEvent.pubkey) || [];
replaceableEventCache.getOutboxRelaysSync(existingEvent.pubkey) || [];
}
// If not in store but we have author hint (from reply "p" tag)
if (cachedOutboxRelays.length === 0 && authorHint) {
cachedOutboxRelays = relayListCache.getOutboxRelaysSync(authorHint) || [];
cachedOutboxRelays =
replaceableEventCache.getOutboxRelaysSync(authorHint) || [];
}
// Limit cached relays to top 3 to avoid too many connections

View File

@@ -1,134 +0,0 @@
/**
* Relay List Cache Service
*
* Wrapper around generic ReplaceableEventCache for NIP-65 relay lists (kind:10002).
* Provides convenient helpers for accessing inbox/outbox relays.
*
* Now uses the generic cache for storage - parsing happens on-demand using applesauce helpers.
*/
import { getInboxes, getOutboxes } from "applesauce-core/helpers";
import { normalizeRelayURL } from "@/lib/relay-url";
import replaceableEventCache from "./replaceable-event-cache";
import type { IEventStore } from "applesauce-core/event-store";
const RELAY_LIST_KIND = 10002;
class RelayListCache {
/**
* Subscribe to EventStore to auto-cache kind:10002 events
* @deprecated - Now handled by generic ReplaceableEventCache
* Kept for backward compatibility with existing code
*/
subscribeToEventStore(_eventStore: IEventStore): void {
console.warn(
"[RelayListCache] subscribeToEventStore is deprecated - kind:10002 is now auto-cached by ReplaceableEventCache",
);
}
/**
* Unsubscribe from EventStore
* @deprecated - Now handled by generic ReplaceableEventCache
* Kept for backward compatibility with existing code
*/
unsubscribe(): void {
console.warn(
"[RelayListCache] unsubscribe is deprecated - managed by ReplaceableEventCache",
);
}
/**
* Get outbox relays from memory cache only (synchronous, fast)
* Used for real-time operations where async Dexie lookup would be too slow
* Returns null if not in memory cache
*/
getOutboxRelaysSync(pubkey: string): string[] | null {
const event = replaceableEventCache.getSync(pubkey, RELAY_LIST_KIND);
if (!event) return null;
// Parse and normalize on-demand (applesauce caches this)
const writeRelays = getOutboxes(event);
return this.normalizeRelays(writeRelays);
}
/**
* Get outbox (write) relays for a pubkey from cache
*/
async getOutboxRelays(pubkey: string): Promise<string[] | null> {
const event = await replaceableEventCache.getEvent(pubkey, RELAY_LIST_KIND);
if (!event) return null;
// Parse and normalize on-demand (applesauce caches this)
const writeRelays = getOutboxes(event);
return this.normalizeRelays(writeRelays);
}
/**
* Get inbox (read) relays for a pubkey from cache
*/
async getInboxRelays(pubkey: string): Promise<string[] | null> {
const event = await replaceableEventCache.getEvent(pubkey, RELAY_LIST_KIND);
if (!event) return null;
// Parse and normalize on-demand (applesauce caches this)
const readRelays = getInboxes(event);
return this.normalizeRelays(readRelays);
}
/**
* Normalize relay URLs and filter invalid ones
*/
private normalizeRelays(relays: string[]): string[] {
return relays
.map((url) => {
try {
return normalizeRelayURL(url);
} catch {
console.warn(`[RelayListCache] Invalid relay URL: ${url}`);
return null;
}
})
.filter((url): url is string => url !== null);
}
/**
* Check if we have a valid cache entry for a pubkey
*/
async has(pubkey: string): Promise<boolean> {
return replaceableEventCache.has(pubkey, RELAY_LIST_KIND);
}
/**
* Invalidate (delete) cache entry for a pubkey
*/
async invalidate(pubkey: string): Promise<void> {
return replaceableEventCache.invalidate(pubkey, RELAY_LIST_KIND);
}
/**
* Get cached relay list entry for a pubkey
* Returns the full cached entry with event and parsed data
*/
async get(
pubkey: string,
): Promise<{ event: any; read: string[]; write: string[] } | null> {
const event = await replaceableEventCache.getEvent(pubkey, RELAY_LIST_KIND);
if (!event) return null;
const read = this.normalizeRelays(getInboxes(event));
const write = this.normalizeRelays(getOutboxes(event));
return { event, read, write };
}
/**
* Clear all cached relay lists (for testing)
*/
async clear(): Promise<void> {
return replaceableEventCache.clearKind(RELAY_LIST_KIND);
}
}
// Singleton instance
export const relayListCache = new RelayListCache();
export default relayListCache;

View File

@@ -7,7 +7,7 @@ import { selectRelaysForFilter } from "./relay-selection";
import { EventStore } from "applesauce-core";
import type { NostrEvent } from "nostr-tools";
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import relayListCache from "./relay-list-cache";
import replaceableEventCache from "./replaceable-event-cache";
// Helper to create valid test events
function createRelayListEvent(
@@ -40,7 +40,7 @@ describe("selectRelaysForFilter", () => {
beforeEach(async () => {
eventStore = new EventStore();
// Clear the relay list cache to ensure test isolation
await relayListCache.clear();
await replaceableEventCache.clearKind(10002);
});
describe("fallback behavior", () => {

View File

@@ -20,7 +20,7 @@ import { selectOptimalRelays } from "applesauce-core/helpers";
import { addressLoader, AGGREGATOR_RELAYS } from "./loaders";
import { normalizeRelayURL } from "@/lib/relay-url";
import liveness from "./relay-liveness";
import relayListCache from "./relay-list-cache";
import replaceableEventCache from "./replaceable-event-cache";
import type {
RelaySelectionResult,
RelaySelectionReasoning,
@@ -92,7 +92,7 @@ async function getOutboxRelaysForPubkey(
): Promise<string[]> {
try {
// Check cache first
const cachedRelays = await relayListCache.getOutboxRelays(pubkey);
const cachedRelays = await replaceableEventCache.getOutboxRelays(pubkey);
if (cachedRelays) {
console.debug(
`[RelaySelection] Using cached outbox relays for ${pubkey.slice(0, 8)} (${cachedRelays.length} relays)`,
@@ -189,7 +189,7 @@ async function getInboxRelaysForPubkey(
): Promise<string[]> {
try {
// Check cache first
const cachedRelays = await relayListCache.getInboxRelays(pubkey);
const cachedRelays = await replaceableEventCache.getInboxRelays(pubkey);
if (cachedRelays) {
console.debug(
`[RelaySelection] Using cached inbox relays for ${pubkey.slice(0, 8)} (${cachedRelays.length} relays)`,

View File

@@ -14,6 +14,9 @@
import type { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers/event";
import { getInboxes, getOutboxes } from "applesauce-core/helpers";
import { normalizeRelayURL } from "@/lib/relay-url";
import { getServersFromEvent } from "./blossom";
import db, { CachedReplaceableEvent } from "./db";
import type { IEventStore } from "applesauce-core/event-store";
import type { Subscription } from "rxjs";
@@ -517,6 +520,77 @@ class ReplaceableEventCache {
return 0;
}
}
// ===== Convenience Helpers for Common Operations =====
/**
* Get outbox (write) relays for a pubkey from kind:10002 (NIP-65)
*/
async getOutboxRelays(pubkey: string): Promise<string[] | null> {
const event = await this.getEvent(pubkey, 10002);
if (!event) return null;
const relays = getOutboxes(event);
return this.normalizeRelays(relays);
}
/**
* Get outbox relays from memory cache only (synchronous, fast)
*/
getOutboxRelaysSync(pubkey: string): string[] | null {
const event = this.getSync(pubkey, 10002);
if (!event) return null;
const relays = getOutboxes(event);
return this.normalizeRelays(relays);
}
/**
* Get inbox (read) relays for a pubkey from kind:10002 (NIP-65)
*/
async getInboxRelays(pubkey: string): Promise<string[] | null> {
const event = await this.getEvent(pubkey, 10002);
if (!event) return null;
const relays = getInboxes(event);
return this.normalizeRelays(relays);
}
/**
* Get blossom servers for a pubkey from kind:10063 (BUD-03)
*/
async getBlossomServers(pubkey: string): Promise<string[] | null> {
const event = await this.getEvent(pubkey, 10063);
if (!event) return null;
return getServersFromEvent(event);
}
/**
* Get blossom servers from memory cache only (synchronous, fast)
*/
getBlossomServersSync(pubkey: string): string[] | null {
const event = this.getSync(pubkey, 10063);
if (!event) return null;
return getServersFromEvent(event);
}
/**
* Normalize relay URLs and filter invalid ones
*/
private normalizeRelays(relays: string[]): string[] {
return relays
.map((url) => {
try {
return normalizeRelayURL(url);
} catch {
console.warn(`[ReplaceableEventCache] Invalid relay URL: ${url}`);
return null;
}
})
.filter((url): url is string => url !== null);
}
}
// Singleton instance