perf: improve loading speed (#116)

This commit is contained in:
Cody Tseng
2025-02-12 22:09:00 +08:00
committed by GitHub
parent 6f91c200a9
commit d5f46690c4
16 changed files with 705 additions and 514 deletions

View File

@@ -0,0 +1,271 @@
import { tagNameEquals } from '@/lib/tag'
import { Event, kinds } from 'nostr-tools'
type TValue<T = any> = {
key: string
value: T
addedAt: number
}
const StoreNames = {
PROFILE_EVENTS: 'profileEvents',
RELAY_LIST_EVENTS: 'relayListEvents',
FOLLOW_LIST_EVENTS: 'followListEvents',
MUTE_LIST_EVENTS: 'muteListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
RELAY_INFO_EVENTS: 'relayInfoEvents'
}
class IndexedDbService {
static instance: IndexedDbService
static getInstance(): IndexedDbService {
if (!IndexedDbService.instance) {
IndexedDbService.instance = new IndexedDbService()
IndexedDbService.instance.init()
}
return IndexedDbService.instance
}
private db: IDBDatabase | null = null
private initPromise: Promise<void> | null = null
init(): Promise<void> {
if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 2)
request.onerror = (event) => {
reject(event)
}
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
this.db = request.result
if (!this.db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) {
this.db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' })
}
if (!this.db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) {
this.db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' })
}
if (!this.db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) {
this.db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' })
}
if (!this.db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) {
this.db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' })
}
if (!this.db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
this.db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' })
}
if (!this.db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
this.db.createObjectStore(StoreNames.RELAY_INFO_EVENTS, { keyPath: 'key' })
}
}
})
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
}
return this.initPromise
}
async putReplaceableEvent(event: Event): Promise<boolean> {
const storeName = this.getStoreNameByKind(event.kind)
if (!storeName) {
return Promise.reject('store name not found')
}
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const getRequest = store.get(event.pubkey)
getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined
if (oldValue && oldValue.value.created_at >= event.created_at) {
return resolve(false)
}
const putRequest = store.put(this.formatValue(event.pubkey, event))
putRequest.onsuccess = () => {
resolve(true)
}
putRequest.onerror = (event) => {
reject(event)
}
}
})
}
async getReplaceableEvent(pubkey: string, kind: number): Promise<Event | undefined> {
const storeName = this.getStoreNameByKind(kind)
if (!storeName) {
return Promise.reject('store name not found')
}
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.get(pubkey)
request.onsuccess = () => {
resolve((request.result as TValue<Event>)?.value)
}
request.onerror = (event) => {
reject(event)
}
})
}
async getMuteDecryptedTags(id: string): Promise<string[][]> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.MUTE_DECRYPTED_TAGS, 'readonly')
const store = transaction.objectStore(StoreNames.MUTE_DECRYPTED_TAGS)
const request = store.get(id)
request.onsuccess = () => {
resolve((request.result as TValue<string[][]>)?.value)
}
request.onerror = (event) => {
reject(event)
}
})
}
async putMuteDecryptedTags(id: string, tags: string[][]): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.MUTE_DECRYPTED_TAGS, 'readwrite')
const store = transaction.objectStore(StoreNames.MUTE_DECRYPTED_TAGS)
const putRequest = store.put(this.formatValue(id, tags))
putRequest.onsuccess = () => {
resolve()
}
putRequest.onerror = (event) => {
reject(event)
}
})
}
async getAllRelayInfoEvents(): Promise<Event[]> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.RELAY_INFO_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.RELAY_INFO_EVENTS)
const request = store.getAll()
request.onsuccess = () => {
resolve((request.result as TValue<Event>[])?.map((item) => item.value))
}
request.onerror = (event) => {
reject(event)
}
})
}
async putRelayInfoEvent(event: Event): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const dValue = event.tags.find(tagNameEquals('d'))?.[1]
if (!dValue) {
return resolve()
}
const transaction = this.db.transaction(StoreNames.RELAY_INFO_EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.RELAY_INFO_EVENTS)
const putRequest = store.put(this.formatValue(dValue, event))
putRequest.onsuccess = () => {
resolve()
}
putRequest.onerror = (event) => {
reject(event)
}
})
}
private getStoreNameByKind(kind: number): string | undefined {
switch (kind) {
case kinds.Metadata:
return StoreNames.PROFILE_EVENTS
case kinds.RelayList:
return StoreNames.RELAY_LIST_EVENTS
case kinds.Contacts:
return StoreNames.FOLLOW_LIST_EVENTS
case kinds.Mutelist:
return StoreNames.MUTE_LIST_EVENTS
default:
return undefined
}
}
private formatValue<T>(key: string, value: T): TValue<T> {
return {
key,
value,
addedAt: Date.now()
}
}
private async cleanUp() {
await this.initPromise
if (!this.db) {
return
}
const expirationTimestamp = Date.now() - 1000 * 60 * 60 * 24 // 1 day
const transaction = this.db!.transaction(Object.values(StoreNames), 'readwrite')
await Promise.allSettled(
Object.values(StoreNames).map((storeName) => {
return new Promise<void>((resolve, reject) => {
const store = transaction.objectStore(storeName)
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
const value: TValue = cursor.value
if (value.addedAt < expirationTimestamp) {
cursor.delete()
}
cursor.continue()
} else {
resolve()
}
}
request.onerror = (event) => {
reject(event)
}
})
})
)
}
}
const instance = IndexedDbService.getInstance()
export default instance