diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 5c434f5f6d..1b77487af9 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -163,8 +163,12 @@ Sentry.init({ const error = hint?.originalException ?? hint?.syntheticException; const value = event.exception?.values?.[0]; + const message = + (typeof value?.value === "string" && value.value) || + getFallbackMessage(hint) || + (typeof event.message === "string" ? event.message : ""); - if (error && isIndexedDBError(error)) { + if ((error && isIndexedDBError(error)) || (message && isIndexedDBError(message))) { handleIndexedDBError(event); } diff --git a/lib/storage/idb-keyval.ts b/lib/storage/idb-keyval.ts new file mode 100644 index 0000000000..006147b554 --- /dev/null +++ b/lib/storage/idb-keyval.ts @@ -0,0 +1,354 @@ +type StoreFn = ( + txMode: IDBTransactionMode, + callback: (store: IDBObjectStore) => Promise | T +) => Promise; + +type RequestLike = { + result?: T; + error?: unknown; + onsuccess: ((this: any, ev: Event) => any) | null; + onerror: ((this: any, ev: Event) => any) | null; + oncomplete?: ((this: any, ev: Event) => any) | null; + onabort?: ((this: any, ev: Event) => any) | null; +}; + +function extractErrorMessage(error: unknown): string { + if (error == null) return ""; + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + if (typeof error === "object") { + const maybeMessage = (error as any)?.message ?? (error as any)?.error; + if (maybeMessage) return String(maybeMessage); + try { + return JSON.stringify(error); + } catch { + return "[unstringifiable object]"; + } + } + if (typeof error === "number") return error.toString(); + if (typeof error === "boolean") return error.toString(); + if (typeof error === "bigint") return error.toString(); + if (typeof error === "symbol") return error.description ?? error.toString(); + if (typeof error === "function") { + return error.name ? `[function ${error.name}]` : "[function]"; + } + return "[unknown error]"; +} + +function extractErrorName(error: unknown): string { + if (error == null) return ""; + if (error instanceof Error) return error.name; + return (error as any)?.name ?? (error as any)?.constructor?.name ?? ""; +} + +function isDatabaseClosingError(error: unknown): boolean { + const name = extractErrorName(error); + const message = extractErrorMessage(error); + + return ( + name === "InvalidStateError" || + /database\s+connection\s+is\s+closing/i.test(message) || + /the\s+database\s+connection\s+is\s+closing/i.test(message) || + /connection\s+is\s+closing/i.test(message) + ); +} + +export function promisifyRequest(request: RequestLike): Promise { + return new Promise((resolve, reject) => { + request.oncomplete = request.onsuccess = () => resolve(request.result as T); + request.onabort = request.onerror = () => reject(request.error); + }); +} + +const dbPromiseCache = new Map>(); + +function openDatabase(dbName: string, storeName: string): Promise { + if (typeof indexedDB === "undefined") { + return Promise.reject(new Error("IndexedDB is not available in this environment.")); + } + + const request = indexedDB.open(dbName); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName); + } + }; + return promisifyRequest(request as unknown as RequestLike); +} + +function getCacheKey(dbName: string, storeName: string): string { + return `${dbName}::${storeName}`; +} + +function getDbPromise(dbName: string, storeName: string): Promise { + const cacheKey = getCacheKey(dbName, storeName); + const cached = dbPromiseCache.get(cacheKey); + if (cached) return cached; + + const dbp = openDatabase(dbName, storeName) + .then((db) => { + const reset = () => { + dbPromiseCache.delete(cacheKey); + }; + + try { + db.addEventListener("close", reset); + } catch {} + + try { + db.addEventListener("versionchange", () => { + reset(); + try { + db.close(); + } catch {} + }); + } catch {} + + return db; + }) + .catch((error) => { + dbPromiseCache.delete(cacheKey); + throw error; + }); + + dbPromiseCache.set(cacheKey, dbp); + return dbp; +} + +function resetDbPromise(dbName: string, storeName: string): void { + dbPromiseCache.delete(getCacheKey(dbName, storeName)); +} + +export function createStore(dbName: string, storeName: string): StoreFn { + return async (txMode, callback) => { + for (let attempt = 0; attempt < 2; attempt++) { + const db = await getDbPromise(dbName, storeName); + + let store: IDBObjectStore; + try { + store = db.transaction(storeName, txMode).objectStore(storeName); + } catch (error) { + if (attempt === 0 && isDatabaseClosingError(error)) { + resetDbPromise(dbName, storeName); + continue; + } + throw error; + } + + try { + return await callback(store); + } catch (error) { + if (attempt === 0 && isDatabaseClosingError(error)) { + resetDbPromise(dbName, storeName); + continue; + } + throw error; + } + } + + throw new Error("IndexedDB operation failed after retry."); + }; +} + +let defaultGetStoreFunc: StoreFn | undefined; + +function defaultGetStore(): StoreFn { + defaultGetStoreFunc ??= createStore("keyval-store", "keyval"); + return defaultGetStoreFunc; +} + +export function get( + key: IDBValidKey, + customStore?: StoreFn +): Promise { + const useStore = customStore ?? defaultGetStore(); + return useStore("readonly", (store) => + promisifyRequest(store.get(key) as unknown as RequestLike) + ); +} + +export function set( + key: IDBValidKey, + value: T, + customStore?: StoreFn +): Promise { + const useStore = customStore ?? defaultGetStore(); + return useStore("readwrite", (store) => { + store.put(value, key); + return promisifyRequest( + store.transaction as unknown as RequestLike + ); + }); +} + +export function setMany( + entries: Array<[IDBValidKey, unknown]>, + customStore?: StoreFn +): Promise { + const useStore = customStore ?? defaultGetStore(); + return useStore("readwrite", (store) => { + entries.forEach((entry) => store.put(entry[1], entry[0])); + return promisifyRequest( + store.transaction as unknown as RequestLike + ); + }); +} + +export function getMany( + keys: IDBValidKey[], + customStore?: StoreFn +): Promise> { + const useStore = customStore ?? defaultGetStore(); + return useStore("readonly", (store) => + Promise.all( + keys.map((key) => + promisifyRequest(store.get(key) as unknown as RequestLike) + ) + ) + ); +} + +export function update( + key: IDBValidKey, + updater: (oldValue: T | undefined) => T, + customStore?: StoreFn +): Promise { + const useStore = customStore ?? defaultGetStore(); + return useStore( + "readwrite", + (store) => + new Promise((resolve, reject) => { + let settled = false; + const transaction = store.transaction; + const rejectOnce = (error: unknown) => { + if (settled) return; + settled = true; + reject(error); + }; + + try { + transaction.onabort = () => + rejectOnce( + transaction.error ?? new Error("IndexedDB transaction aborted") + ); + transaction.onerror = () => + rejectOnce( + transaction.error ?? new Error("IndexedDB transaction error") + ); + } catch {} + + const request = store.get(key) as unknown as IDBRequest; + + request.onerror = () => + rejectOnce(request.error ?? new Error("IndexedDB request failed")); + + request.onsuccess = () => { + try { + store.put(updater(request.result), key); + settled = true; + resolve(promisifyRequest(transaction as unknown as RequestLike)); + } catch (err) { + rejectOnce(err); + } + }; + }) + ); +} + +export function del( + key: IDBValidKey, + customStore?: StoreFn +): Promise { + const useStore = customStore ?? defaultGetStore(); + return useStore("readwrite", (store) => { + store.delete(key); + return promisifyRequest( + store.transaction as unknown as RequestLike + ); + }); +} + +export function delMany( + keys: IDBValidKey[], + customStore?: StoreFn +): Promise { + const useStore = customStore ?? defaultGetStore(); + return useStore("readwrite", (store) => { + keys.forEach((key) => store.delete(key)); + return promisifyRequest( + store.transaction as unknown as RequestLike + ); + }); +} + +export function clear(customStore?: StoreFn): Promise { + const useStore = customStore ?? defaultGetStore(); + return useStore("readwrite", (store) => { + store.clear(); + return promisifyRequest( + store.transaction as unknown as RequestLike + ); + }); +} + +function eachCursor( + store: IDBObjectStore, + callback: (cursor: IDBCursorWithValue) => void +): Promise { + store.openCursor().onsuccess = function () { + if (!this.result) return; + callback(this.result); + this.result.continue(); + }; + return promisifyRequest(store.transaction as unknown as RequestLike); +} + +export function keys(customStore?: StoreFn): Promise { + const useStore = customStore ?? defaultGetStore(); + return useStore("readonly", (store) => { + if (store.getAllKeys) { + return promisifyRequest( + store.getAllKeys() as unknown as RequestLike + ); + } + const items: IDBValidKey[] = []; + return eachCursor(store, (cursor) => items.push(cursor.key)).then( + () => items + ); + }); +} + +export function values(customStore?: StoreFn): Promise { + const useStore = customStore ?? defaultGetStore(); + return useStore("readonly", (store) => { + if (store.getAll) { + return promisifyRequest(store.getAll() as unknown as RequestLike); + } + const items: T[] = []; + return eachCursor(store, (cursor) => items.push(cursor.value as T)).then( + () => items + ); + }); +} + +export function entries( + customStore?: StoreFn +): Promise> { + const useStore = customStore ?? defaultGetStore(); + return useStore("readonly", (store) => { + if (store.getAll && store.getAllKeys) { + return Promise.all([ + promisifyRequest( + store.getAllKeys() as unknown as RequestLike + ), + promisifyRequest(store.getAll() as unknown as RequestLike), + ]).then(([k, v]) => k.map((key, i) => [key, v[i]])); + } + + const items: Array<[IDBValidKey, T]> = []; + return eachCursor(store, (cursor) => + items.push([cursor.key, cursor.value as T]) + ).then(() => items); + }); +} diff --git a/next.config.mjs b/next.config.mjs index 68c60c4e05..d004b85e51 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,6 +7,7 @@ import { } from "next/constants.js"; import { execSync } from "node:child_process"; import fs from "node:fs"; +import path from "node:path"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const sentryEnabled = Boolean(process.env.SENTRY_DSN); @@ -177,6 +178,10 @@ function sharedConfig(publicEnv, assetPrefix) { config.resolve.alias.encoding = false; config.resolve.alias["@react-native-async-storage/async-storage"] = false; config.resolve.alias["react-native"] = false; + config.resolve.alias["idb-keyval"] = path.resolve( + process.cwd(), + "lib/storage/idb-keyval.ts" + ); if (!dev && !isServer) config.devtool = "source-map"; config.optimization.minimize = false; return config; @@ -187,6 +192,7 @@ function sharedConfig(publicEnv, assetPrefix) { encoding: "./stubs/empty.js", "@react-native-async-storage/async-storage": "./stubs/empty.js", "react-native": "./stubs/empty.js", + "idb-keyval": "./lib/storage/idb-keyval.ts", }, }, serverExternalPackages: ["@reown/appkit", "@reown/appkit-adapter-wagmi"],