improve dns identity caching

This commit is contained in:
hzrd149
2023-09-07 21:14:18 -05:00
parent 98ed8685b9
commit a5e7f59289
5 changed files with 89 additions and 96 deletions

View File

@@ -1,22 +1,20 @@
import { Spinner, Text, Tooltip } from "@chakra-ui/react"; import { Text, Tooltip } from "@chakra-ui/react";
import { useDnsIdentity } from "../hooks/use-dns-identity"; import { useDnsIdentity } from "../hooks/use-dns-identity";
import { useUserMetadata } from "../hooks/use-user-metadata"; import { useUserMetadata } from "../hooks/use-user-metadata";
import { VerificationFailed, VerificationMissing, VerifiedIcon } from "./icons"; import { VerificationFailed, VerificationMissing, VerifiedIcon } from "./icons";
export const UserDnsIdentityIcon = ({ pubkey, onlyIcon }: { pubkey: string; onlyIcon?: boolean }) => { export const UserDnsIdentityIcon = ({ pubkey, onlyIcon }: { pubkey: string; onlyIcon?: boolean }) => {
const metadata = useUserMetadata(pubkey); const metadata = useUserMetadata(pubkey);
const { identity, loading, error } = useDnsIdentity(metadata?.nip05); const identity = useDnsIdentity(metadata?.nip05);
if (!metadata?.nip05) { if (!metadata?.nip05) {
return null; return null;
} }
const renderIcon = () => { const renderIcon = () => {
if (loading) { if (identity === undefined) {
return <Spinner size="xs" ml="1" title={metadata.nip05} />;
} else if (error) {
return <VerificationFailed color="yellow.500" />; return <VerificationFailed color="yellow.500" />;
} else if (!identity) { } else if (identity === null) {
return <VerificationMissing color="red.500" />; return <VerificationMissing color="red.500" />;
} else if (pubkey === identity.pubkey) { } else if (pubkey === identity.pubkey) {
return <VerifiedIcon color="purple.500" />; return <VerifiedIcon color="purple.500" />;

View File

@@ -1,15 +1,11 @@
import { useAsync } from "react-use";
import dnsIdentityService from "../services/dns-identity"; import dnsIdentityService from "../services/dns-identity";
import { useMemo } from "react";
import useSubject from "./use-subject";
export function useDnsIdentity(address: string | undefined) { export function useDnsIdentity(address: string | undefined) {
const { value, loading, error } = useAsync(async () => { const subject = useMemo(() => {
if (!address) return; if (address) return dnsIdentityService.getIdentity(address);
return dnsIdentityService.getIdentity(address);
}, [address]); }, [address]);
return { return useSubject(subject);
identity: value,
error,
loading,
};
} }

View File

@@ -1,6 +1,10 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import db from "./db"; import db from "./db";
import _throttle from "lodash.throttle";
import { fetchWithCorsFallback } from "../helpers/cors"; import { fetchWithCorsFallback } from "../helpers/cors";
import { SuperMap } from "../classes/super-map";
import Subject from "../classes/subject";
export function parseAddress(address: string): { name?: string; domain?: string } { export function parseAddress(address: string): { name?: string; domain?: string } {
const parts = address.trim().toLowerCase().split("@"); const parts = address.trim().toLowerCase().split("@");
@@ -26,106 +30,101 @@ function getIdentityFromJson(name: string, domain: string, json: IdentityJson):
return { name, domain, pubkey, relays }; return { name, domain, pubkey, relays };
} }
async function fetchAllIdentities(domain: string) { class DnsIdentityService {
const json = await fetchWithCorsFallback(`//${domain}/.well-known/nostr.json`).then( identities = new SuperMap<string, Subject<DnsIdentity | null>>(() => new Subject());
(res) => res.json() as Promise<IdentityJson>,
);
await addToCache(domain, json); async fetchIdentity(address: string) {
} const { name, domain } = parseAddress(address);
if (!name || !domain) throw new Error("invalid address");
async function fetchIdentity(address: string) { const json = await fetchWithCorsFallback(`https://${domain}/.well-known/nostr.json?name=${name}`)
const { name, domain } = parseAddress(address); .then((res) => res.json() as Promise<IdentityJson>)
if (!name || !domain) throw new Error("invalid address"); .then((json) => {
// convert all keys in names, and relays to lower case
const json = await fetchWithCorsFallback(`https://${domain}/.well-known/nostr.json?name=${name}`) if (json.names) {
.then((res) => res.json() as Promise<IdentityJson>) for (const [name, pubkey] of Object.entries(json.names)) {
.then((json) => { delete json.names[name];
// convert all keys in names, and relays to lower case json.names[name.toLowerCase()] = pubkey;
if (json.names) { }
for (const [name, pubkey] of Object.entries(json.names)) {
delete json.names[name];
json.names[name.toLowerCase()] = pubkey;
} }
} if (json.relays) {
if (json.relays) { for (const [name, pubkey] of Object.entries(json.relays)) {
for (const [name, pubkey] of Object.entries(json.relays)) { delete json.relays[name];
delete json.relays[name]; json.relays[name.toLowerCase()] = pubkey;
json.relays[name.toLowerCase()] = pubkey; }
} }
} return json;
return json; });
});
await addToCache(domain, json); await this.addToCache(domain, json);
return getIdentityFromJson(name, domain, json); return getIdentityFromJson(name, domain, json);
} }
const inMemoryCache = new Map<string, DnsIdentity>(); async addToCache(domain: string, json: IdentityJson) {
const now = dayjs().unix();
const transaction = db.transaction("dnsIdentifiers", "readwrite");
async function addToCache(domain: string, json: IdentityJson) { for (const name of Object.keys(json.names)) {
const now = dayjs().unix(); const identity = getIdentityFromJson(name, domain, json);
const transaction = db.transaction("dnsIdentifiers", "readwrite"); if (identity) {
const address = `${name}@${domain}`;
for (const name of Object.keys(json.names)) { // add to memory cache
const identity = getIdentityFromJson(name, domain, json); this.identities.get(address).next(identity);
if (identity) {
const id = `${name}@${domain}`;
// add to memory cache // ad to db cache
inMemoryCache.set(id, identity); if (transaction.store.put) {
await transaction.store.put({ ...identity, updated: now }, address);
// ad to db cache }
if (transaction.store.put) {
await transaction.store.put({ ...identity, updated: now }, id);
} }
} }
} await transaction.done;
await transaction.done;
}
async function getIdentity(address: string, alwaysFetch = false) {
if (!inMemoryCache.has(address)) {
const fromDb = await db.get("dnsIdentifiers", address);
if (fromDb) inMemoryCache.set(address, fromDb);
} }
const cached = inMemoryCache.get(address); loading = new Set<string>();
if (cached && !alwaysFetch) return cached; getIdentity(address: string, alwaysFetch = false) {
const sub = this.identities.get(address);
return fetchIdentity(address); if (this.loading.has(address)) return sub;
} this.loading.add(address);
async function pruneCache() { db.get("dnsIdentifiers", address).then((fromDb) => {
const keys = await db.getAllKeysFromIndex( if (fromDb) sub.next(fromDb);
"dnsIdentifiers", this.loading.delete(address);
"updated", });
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix()),
);
for (const pubkey of keys) { if (!sub.value || alwaysFetch) {
db.delete("dnsIdentifiers", pubkey); this.fetchIdentity(address)
.then((identity) => {
sub.next(identity ?? null);
})
.finally(() => {
this.loading.delete(address);
});
}
return sub;
}
async pruneCache() {
const keys = await db.getAllKeysFromIndex(
"dnsIdentifiers",
"updated",
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix()),
);
for (const pubkey of keys) {
db.delete("dnsIdentifiers", pubkey);
}
} }
} }
const pending: Record<string, ReturnType<typeof getIdentity> | undefined> = {}; export const dnsIdentityService = new DnsIdentityService();
function dedupedGetIdentity(address: string, alwaysFetch = false) {
if (pending[address]) return pending[address];
return (pending[address] = getIdentity(address, alwaysFetch).then((v) => {
delete pending[address];
return v;
}));
}
export const dnsIdentityService = { setInterval(() => {
fetchAllIdentities, dnsIdentityService.pruneCache();
fetchIdentity, }, 1000 * 60);
getIdentity: dedupedGetIdentity,
pruneCache,
};
setTimeout(() => pruneCache(), 1000 * 60 * 20);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
// @ts-ignore // @ts-ignore

View File

@@ -43,7 +43,7 @@ export default function LoginNip05View() {
async () => { async () => {
if (nip05) { if (nip05) {
try { try {
const id = await dnsIdentityService.getIdentity(nip05, true); const id = await dnsIdentityService.fetchIdentity(nip05);
setPubkey(id?.pubkey); setPubkey(id?.pubkey);
setRelays(id?.relays); setRelays(id?.relays);
} catch (e) {} } catch (e) {}

View File

@@ -122,7 +122,7 @@ const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => {
if (!address) return true; if (!address) return true;
if (!address.includes("@")) return "Invalid address"; if (!address.includes("@")) return "Invalid address";
try { try {
const id = await dnsIdentityService.getIdentity(address); const id = await dnsIdentityService.fetchIdentity(address);
if (!id) return "Cant find NIP-05 ID"; if (!id) return "Cant find NIP-05 ID";
if (id.pubkey !== account.pubkey) return "Pubkey dose not match"; if (id.pubkey !== account.pubkey) return "Pubkey dose not match";
} catch (e) { } catch (e) {