UNPKG

@nostr-fetch/kernel

Version:
414 lines (368 loc) 11.3 kB
/** * The data structure of Nostr event. */ export type NostrEvent = { id: string; pubkey: string; created_at: number; kind: number; tags: string[][]; content: string; sig: string; }; /** * Standardized Nostr event kinds. * cf. https://github.com/nostr-protocol/nips#event-kinds * * @deprecated use literal kind numbers instead. */ export const eventKind = { metadata: 0, text: 1, recommendRelay: 2, contacts: 3, encryptedDirectMessage: 4, eventDeletion: 5, repost: 6, reaction: 7, badgeAward: 8, genericRepost: 16, channelCreation: 40, channelMetadata: 41, channelMessage: 42, channelHideMessage: 43, channelMuteUser: 44, fileMetadata: 1063, liveChatMessage: 1311, report: 1984, label: 1985, communityPostApproval: 4550, zapRequest: 9734, zap: 9735, muteList: 10000, pinList: 10001, relayList: 10002, walletInfo: 13194, clientAuth: 22242, walletRequest: 23194, walletResponse: 23195, nostrConnect: 24133, httpAuth: 27235, categorizedPeopleList: 30000, categorizedBookmarkList: 30001, profileBadges: 30008, badgeDefinition: 30009, marketplaceStall: 30017, marketplaceProduct: 30018, article: 30023, draftArticle: 30024, appSpecificData: 30078, liveEvent: 30311, classifiedListing: 30402, draftClassifiedListing: 30403, dateBasedCalendarEvent: 31922, timeBasedCalendarEvent: 31923, calendar: 31924, calendarEventRsvp: 31925, handlerRecommendation: 31989, handlerInformation: 31990, communityDefinition: 34550, } as const; /** * Keys of filter props for tag queries. */ type TagQueryKey = `#${string}`; // [A-Za-z] const isLatinAlphabet = (charCode: number) => (0x41 <= charCode && charCode <= 0x5a) || (0x61 <= charCode && charCode <= 0x7a); /** * Checks if the given string is a "queryable" tag name. */ const isQueryableTagName = (s: string): boolean => s.length === 1 && isLatinAlphabet(s.charCodeAt(0)); /** * Checks if the given string is a valid key of tag query in filter. * * A valid tag query key is a string that starts with `#` and followed by a single Latin alphabet (`[A-Za-z]`). */ export const isValidTagQueryKey = (s: string): boolean => s.startsWith("#") && isQueryableTagName(s.substring(1)); /** * Filter for Nostr event subscription. */ export type Filter = { ids?: string[]; kinds?: number[]; authors?: string[]; since?: number; until?: number; limit?: number; search?: string; } & { [tag in TagQueryKey]: string[]; }; // client to relay messages type C2RReq = [type: "REQ", subId: string, ...filters: Filter[]]; type C2RClose = [type: "CLOSE", subId: string]; export type C2RMessage = C2RReq | C2RClose; export type C2RMessageType = C2RMessage[0]; // relay to client messages type R2CEvent = [type: "EVENT", subId: string, event: NostrEvent]; type R2CEose = [type: "EOSE", subId: string]; type R2CClosed = [type: "CLOSED", subId: string, message: string]; type R2CNotice = [type: "NOTICE", notice: string]; export type R2CMessage = R2CEvent | R2CEose | R2CClosed | R2CNotice; export type R2CMessageType = R2CMessage[0]; export type R2CSubMessage = R2CEvent | R2CEose; export type R2CSubMessageType = R2CSubMessage[0]; const supportedR2CMsgTypes: R2CMessageType[] = ["EVENT", "EOSE", "CLOSED", "NOTICE"]; const isSupportedR2CMsgType = (s: string): s is R2CMessageType => (supportedR2CMsgTypes as string[]).includes(s); export const parseR2CMessage = (rawMsg: string): R2CMessage | undefined => { let parsed: unknown; try { parsed = JSON.parse(rawMsg) as unknown; } catch (err) { console.error("failed to parse R2C message as JSON:", err); return undefined; } if (!Array.isArray(parsed) || parsed.length === 0 || typeof parsed[0] !== "string") { console.error("malformed R2C message"); return undefined; } const msgType = parsed[0] as string; if (!isSupportedR2CMsgType(msgType)) { console.error("unsupported R2C message type:", parsed[0]); return undefined; } switch (msgType) { case "EVENT": { if (parsed.length !== 3) { console.error("malformed R2C EVENT"); return undefined; } const [, subId, ev] = parsed; if (typeof subId !== "string" || typeof ev !== "object" || ev === null) { console.error("malformed R2C EVENT"); return undefined; } if (!validateEvent(ev)) { console.error("malformed event in R2C EVENT"); return undefined; } return parsed as R2CEvent; } case "EOSE": { if (parsed.length !== 2 || typeof parsed[1] !== "string") { console.error("malformed R2C EOSE"); return undefined; } return parsed as R2CEose; } case "CLOSED": { if (parsed.length !== 3 || typeof parsed[1] !== "string" || typeof parsed[2] !== "string") { console.error("malformed R2C CLOSED"); return undefined; } return parsed as R2CClosed; } case "NOTICE": { if (parsed.length !== 2) { console.error("malformed R2C NOTICE"); return undefined; } return parsed as R2CNotice; } default: return undefined; } }; // schema validation for Nostr events export const validateEvent = (rawEv: Record<string, unknown>): rawEv is NostrEvent => { // id: 32-bytes lowercase hex-encoded sha256 if (!("id" in rawEv) || typeof rawEv["id"] !== "string" || !is32BytesHexStr(rawEv["id"])) { return false; } // pubkey: 32-bytes lowercase hex-encoded public key if ( !("pubkey" in rawEv) || typeof rawEv["pubkey"] !== "string" || !is32BytesHexStr(rawEv["pubkey"]) ) { return false; } // created_at: unix timestamp in seconds if (!("created_at" in rawEv) || typeof rawEv["created_at"] !== "number") { return false; } // kind: integer if (!("kind" in rawEv) || typeof rawEv["kind"] !== "number") { return false; } // tags: array of arrays of non-null strings if (!("tags" in rawEv) || !Array.isArray(rawEv["tags"])) { return false; } if (rawEv["tags"].some((tag) => !Array.isArray(tag) || tag.some((e) => typeof e !== "string"))) { return false; } // content: string if (!("content" in rawEv) || typeof rawEv["content"] !== "string") { return false; } // sig: 64-bytes hex of the signature if (!("sig" in rawEv) || typeof rawEv["sig"] !== "string" || !is64BytesHexStr(rawEv["sig"])) { return false; } return true; }; const is32BytesHexStr = (s: string): boolean => { return /^[a-f0-9]{64}$/.test(s); }; const is64BytesHexStr = (s: string): boolean => { return /^[a-f0-9]{128}$/.test(s); }; /** * Type of functions that verify the signature of the Nostr event. */ export type EventVerifier = (event: NostrEvent) => boolean | Promise<boolean>; /** * no-op `EventVerifier` which regards all events have a valid signature. * * @example * // emulating `skipVerification: true` * import { NostrFetcher, noopVerifier } from "nostr-fetch"; * * const fetcher = NostrFetcher.init(); * const events = fetcher.allEventsIterator({ ... }, { ... }, { eventVerifier: noopVerifier }); */ export const noopVerifier: EventVerifier = (_: NostrEvent) => true; type CompiledFilter = { ids: Set<string> | undefined; kinds: Set<number> | undefined; authors: Set<string> | undefined; tags: [string, Set<string>][]; since: number | undefined; until: number | undefined; }; const compileFilter = (f: Filter): CompiledFilter => { const ids = f.ids ? new Set(f.ids) : undefined; const kinds = f.kinds ? new Set(f.kinds) : undefined; const authors = f.authors ? new Set(f.authors) : undefined; const tags: [string, Set<string>][] = []; for (const k of Object.keys(f)) { if (k.startsWith("#") && k.length === 2) { tags.push([k.charAt(1), new Set(f[k as TagQueryKey] ?? [])] as [string, Set<string>]); } } return { ids, kinds, authors, tags, since: f.since, until: f.until }; }; const getTagValuesByName = (ev: NostrEvent, tagName: string): string[] => ev.tags.filter((t) => t[0] === tagName).map((t) => t[1] ?? ""); const matchWithCompiledFilter = (f: CompiledFilter, ev: NostrEvent): boolean => { if (f.ids !== undefined && !f.ids.has(ev.id)) { return false; } if (f.kinds !== undefined && !f.kinds.has(ev.kind)) { return false; } if (f.authors !== undefined && !f.authors.has(ev.pubkey)) { return false; } if (f.since !== undefined && ev.created_at < f.since) { return false; } if (f.until !== undefined && ev.created_at > f.until) { return false; } const tagMatched = f.tags.every(([tagName, queryVals]) => { const tagVals = getTagValuesByName(ev, tagName); if (tagVals.length === 0) { // required tag is missing return false; } return tagVals.some((e) => queryVals.has(e)); }); return tagMatched; }; export class FilterMatcher { #filters: CompiledFilter[]; constructor(filters: Filter[]) { this.#filters = filters.map(compileFilter); } public match(ev: NostrEvent): boolean { return this.#filters.some((f) => matchWithCompiledFilter(f, ev)); } } /* Check Relay's Capabilities */ /** * Queries supported NIP numbers of the given relay. */ export const querySupportedNips = async (relayUrl: string): Promise<Set<number>> => { try { const httpUrl = toHttpUrl(relayUrl); const resp = await fetch(httpUrl, { headers: { Accept: "application/nostr+json" }, signal: AbortSignal.timeout(5000), }); if (!resp.ok) { console.error("relay information response is not ok"); return new Set(); } const relayInfo = await resp.json(); if (!relayInfoHasSupportedNips(relayInfo)) { console.error("relay information document doesn't have proper 'supported_nips' property"); return new Set(); } return new Set(relayInfo.supported_nips); } catch (err) { console.error(err); return new Set(); } }; const toHttpUrl = (url: string): string => { const u = new URL(url); switch (u.protocol) { case "wss:": u.protocol = "https:"; break; case "ws:": u.protocol = "http:"; break; } return u.toString(); }; type RelayInfoWithSupportedNips = { supported_nips: number[]; }; const relayInfoHasSupportedNips = (relayInfo: unknown): relayInfo is RelayInfoWithSupportedNips => typeof relayInfo === "object" && relayInfo !== null && "supported_nips" in relayInfo && Array.isArray(relayInfo.supported_nips) && (relayInfo.supported_nips.length === 0 || relayInfo.supported_nips.every((e: unknown) => typeof e === "number")); /* Utilities */ // utility to generate a random subscription ID export const generateSubId = () => { return Date.now().toString() + Math.random().toString(32).substring(2, 4); }; const reqErrRegexps = [ /^too many concurrent REQs$/i, /^Subscription rejected/i, /^invalid:(.*)must (contain|be) less than or equal to/i, /^message too large$/i, /^Maximum concurrent subscription count reached$/i, ]; /** * Checks if the NOTICE message seems to have to do with REQs by fetcher. * * Considers following relay implementations: * * - strfry * - nostream * - nostr-rs-relay * - relayer */ export const isNoticeForReqError = (notice: string): boolean => reqErrRegexps.some((r) => r.test(notice));