@nostr-fetch/kernel
Version:
Kernel of nostr-fetch
414 lines (368 loc) • 11.3 kB
text/typescript
/**
* 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));