web: use own kvstore instead of localforage

localforage is unmaintained and also has a huge footprint not to mention
the performance issues.
This commit is contained in:
Abdullah Atta
2023-07-03 06:43:10 +05:00
committed by Abdullah Atta
parent 44782d552d
commit f91ada3c48
6 changed files with 1395 additions and 200 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,6 @@
"@notesnook/crypto-worker": "*", "@notesnook/crypto-worker": "*",
"@notesnook/desktop": "*", "@notesnook/desktop": "*",
"@notesnook/editor": "*", "@notesnook/editor": "*",
"@notesnook/localforage-getitems": "^1.4.4",
"@notesnook/logger": "*", "@notesnook/logger": "*",
"@notesnook/streamable-fs": "*", "@notesnook/streamable-fs": "*",
"@notesnook/theme": "*", "@notesnook/theme": "*",
@@ -47,8 +46,6 @@
"hash-wasm": "^4.9.0", "hash-wasm": "^4.9.0",
"hotkeys-js": "^3.8.3", "hotkeys-js": "^3.8.3",
"immer": "^9.0.6", "immer": "^9.0.6",
"localforage": "^1.10.0",
"localforage-driver-memory": "^1.0.5",
"mac-scrollbar": "^0.10.3", "mac-scrollbar": "^0.10.3",
"marked": "^4.1.0", "marked": "^4.1.0",
"pdfjs-dist": "3.6.172", "pdfjs-dist": "3.6.172",
@@ -81,8 +78,6 @@
"@types/react": "17.0.2", "@types/react": "17.0.2",
"@types/react-dom": "17.0.2", "@types/react-dom": "17.0.2",
"@types/react-modal": "^3.13.1", "@types/react-modal": "^3.13.1",
"@vitejs/plugin-react": "^4.0.0",
"@vitejs/plugin-react-refresh": "^1.3.6",
"@vitejs/plugin-react-swc": "^3.3.2", "@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"buffer": "^6.0.3", "buffer": "^6.0.3",
@@ -102,7 +97,6 @@
"vite-plugin-env-compatible": "^1.1.1", "vite-plugin-env-compatible": "^1.1.1",
"vite-plugin-pwa": "^0.16.3", "vite-plugin-pwa": "^0.16.3",
"vite-plugin-svgr": "^3.2.0", "vite-plugin-svgr": "^3.2.0",
"vite-plugin-top-level-await": "^1.3.1",
"vitest": "^0.32.0", "vitest": "^0.32.0",
"workbox-core": "^7.0.0", "workbox-core": "^7.0.0",
"workbox-expiration": "^7.0.0", "workbox-expiration": "^7.0.0",

View File

@@ -18,7 +18,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import "web-streams-polyfill/dist/ponyfill"; import "web-streams-polyfill/dist/ponyfill";
import localforage from "localforage";
import { xxhash64, createXXHash64 } from "hash-wasm"; import { xxhash64, createXXHash64 } from "hash-wasm";
import axios, { AxiosProgressEvent } from "axios"; import axios, { AxiosProgressEvent } from "axios";
import { AppEventManager, AppEvents } from "../common/app-events"; import { AppEventManager, AppEvents } from "../common/app-events";
@@ -37,6 +36,7 @@ import { Base64DecoderStream } from "../utils/streams/base64-decoder-stream";
import { toBlob } from "@notesnook-importer/core/dist/src/utils/stream"; import { toBlob } from "@notesnook-importer/core/dist/src/utils/stream";
import { Cipher, OutputFormat, SerializedKey } from "@notesnook/crypto"; import { Cipher, OutputFormat, SerializedKey } from "@notesnook/crypto";
import { IDataType } from "hash-wasm/dist/lib/util"; import { IDataType } from "hash-wasm/dist/lib/util";
import { IndexedDBKVStore } from "./key-value";
const ABYTES = 17; const ABYTES = 17;
const CHUNK_SIZE = 512 * 1024; const CHUNK_SIZE = 512 * 1024;
@@ -53,7 +53,7 @@ async function writeEncryptedFile(
) { ) {
const crypto = await getNNCrypto(); const crypto = await getNNCrypto();
if (!localforage.supports(localforage.INDEXEDDB)) if (!IndexedDBKVStore.isIndexedDBSupported())
throw new Error("This browser does not support IndexedDB."); throw new Error("This browser does not support IndexedDB.");
if (await streamablefs.exists(hash)) await streamablefs.deleteFile(hash); if (await streamablefs.exists(hash)) await streamablefs.deleteFile(hash);

View File

@@ -0,0 +1,327 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Config from "../utils/config";
export interface IKVStore {
/**
* Get a value by its key.
*
* @param key
*/
get<T>(key: string): Promise<T | undefined>;
/**
* Set a value with a key.
*
* @param key
* @param value
*/
set(key: string, value: any): Promise<void>;
/**
* Set multiple values at once. This is faster than calling set() multiple times.
* It's also atomic if one of the pairs can't be added, none will be added.
*
* @param entries Array of entries, where each entry is an array of `[key, value]`.
*/
setMany(entries: [string, any][]): Promise<void>;
/**
* Get multiple values by their keys
*
* @param keys
*/
getMany<T>(keys: string[]): Promise<[string, T][]>;
/**
* Delete a particular key from the store.
*
* @param key
*/
delete(key: string): Promise<void>;
/**
* Delete multiple keys at once.
*
* @param keys List of keys to delete.
*/
deleteMany(keys: string[]): Promise<void>;
/**
* Clear all values in the store.
*
*/
clear(): Promise<void>;
keys(): Promise<string[]>;
values<T>(): Promise<T[]>;
entries<T>(): Promise<[string, T][]>;
}
export class LocalStorageKVStore implements IKVStore {
get<T>(key: string): Promise<T | undefined> {
return Promise.resolve(Config.get(key));
}
set(key: string, value: any): Promise<void> {
return Promise.resolve(Config.set(key, value));
}
setMany(entries: [string, any][]): Promise<void> {
for (const entry of entries) {
Config.set(entry[0], entry[1]);
}
return Promise.resolve();
}
getMany<T>(keys: string[]): Promise<[string, T][]> {
const entries: [string, T][] = [];
for (const key of keys) {
entries.push([key, Config.get(key)]);
}
return Promise.resolve(entries);
}
delete(key: string): Promise<void> {
return Promise.resolve(Config.remove(key));
}
deleteMany(keys: string[]): Promise<void> {
for (const key of keys) {
Config.remove(key);
}
return Promise.resolve();
}
clear(): Promise<void> {
Config.clear();
return Promise.resolve();
}
keys(): Promise<string[]> {
return Promise.resolve(Object.keys(Config.all()));
}
values<T>(): Promise<T[]> {
return Promise.resolve(Object.values<T>(Config.all()));
}
entries<T>(): Promise<[string, T][]> {
return Promise.resolve(Object.entries<T>(Config.all()));
}
}
export class MemoryKVStore implements IKVStore {
private storage: Record<string, any> = {};
get<T>(key: string): Promise<T | undefined> {
return Promise.resolve(this.storage[key]);
}
set(key: string, value: any): Promise<void> {
this.storage[key] = value;
return Promise.resolve();
}
setMany(entries: [string, any][]): Promise<void> {
for (const entry of entries) {
this.storage[entry[0]] = entry[1];
}
return Promise.resolve();
}
getMany<T>(keys: string[]): Promise<[string, T][]> {
const entries: [string, T][] = [];
for (const key of keys) {
entries.push([key, this.storage[key]]);
}
return Promise.resolve(entries);
}
delete(key: string): Promise<void> {
delete this.storage[key];
return Promise.resolve();
}
deleteMany(keys: string[]): Promise<void> {
for (const key of keys) {
delete this.storage[key];
}
return Promise.resolve();
}
clear(): Promise<void> {
this.storage = {};
return Promise.resolve();
}
keys(): Promise<string[]> {
return Promise.resolve(Object.keys(this.storage));
}
values<T>(): Promise<T[]> {
return Promise.resolve(Object.values<T>(this.storage));
}
entries<T>(): Promise<[string, T][]> {
return Promise.resolve(Object.entries<T>(this.storage));
}
}
export type UseStore = <T>(
txMode: IDBTransactionMode,
callback: (store: IDBObjectStore) => T | PromiseLike<T>
) => Promise<T>;
export class IndexedDBKVStore implements IKVStore {
store: UseStore;
static isIndexedDBSupported(): boolean {
return "indexedDB" in window;
}
constructor(databaseName: string, storeName: string) {
this.store = this.createStore(databaseName, storeName);
}
private promisifyRequest<T = undefined>(
request: IDBRequest<T> | IDBTransaction
): Promise<T> {
return new Promise<T>((resolve, reject) => {
// @ts-ignore - file size hacks
request.oncomplete = request.onsuccess = () => resolve(request.result);
// @ts-ignore - file size hacks
request.onabort = request.onerror = () => reject(request.error);
});
}
private createStore(dbName: string, storeName: string): UseStore {
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
const dbp = this.promisifyRequest(request);
return (txMode, callback) =>
dbp.then((db) =>
callback(db.transaction(storeName, txMode).objectStore(storeName))
);
}
private eachCursor(
store: IDBObjectStore,
callback: (cursor: IDBCursorWithValue) => void
): Promise<void> {
store.openCursor().onsuccess = function () {
if (!this.result) return;
callback(this.result);
this.result.continue();
};
return this.promisifyRequest(store.transaction);
}
get<T>(key: string): Promise<T | undefined> {
return this.store("readonly", (store) =>
this.promisifyRequest(store.get(key))
);
}
set(key: string, value: any): Promise<void> {
return this.store("readwrite", (store) => {
store.put(value, key);
return this.promisifyRequest(store.transaction);
});
}
setMany(entries: [string, any][]): Promise<void> {
return this.store("readwrite", (store) => {
entries.forEach((entry) => store.put(entry[1], entry[0]));
return this.promisifyRequest(store.transaction);
});
}
getMany<T>(keys: string[]): Promise<[string, T][]> {
return this.store("readonly", (store) =>
Promise.all(
keys.map(async (key) => [
key,
await this.promisifyRequest(store.get(key))
])
)
);
}
delete(key: string): Promise<void> {
return this.store("readwrite", (store) => {
store.delete(key);
return this.promisifyRequest(store.transaction);
});
}
deleteMany(keys: string[]): Promise<void> {
return this.store("readwrite", (store: IDBObjectStore) => {
keys.forEach((key: IDBValidKey) => store.delete(key));
return this.promisifyRequest(store.transaction);
});
}
clear(): Promise<void> {
return this.store("readwrite", (store) => {
store.clear();
return this.promisifyRequest(store.transaction);
});
}
keys<KeyType extends IDBValidKey>(): Promise<KeyType[]> {
return this.store("readonly", (store) => {
// Fast path for modern browsers
if (store.getAllKeys) {
return this.promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>
);
}
const items: KeyType[] = [];
return this.eachCursor(store, (cursor) =>
items.push(cursor.key as KeyType)
).then(() => items);
});
}
values<T = any>(): Promise<T[]> {
return this.store("readonly", (store) => {
// Fast path for modern browsers
if (store.getAll) {
return this.promisifyRequest(store.getAll() as IDBRequest<T[]>);
}
const items: T[] = [];
return this.eachCursor(store, (cursor) =>
items.push(cursor.value as T)
).then(() => items);
});
}
entries<KeyType extends IDBValidKey, ValueType = any>(): Promise<
[KeyType, ValueType][]
> {
return this.store("readonly", (store) => {
// Fast path for modern browsers
// (although, hopefully we'll get a simpler path some day)
if (store.getAll && store.getAllKeys) {
return Promise.all([
this.promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>
),
this.promisifyRequest(store.getAll() as IDBRequest<ValueType[]>)
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]]));
}
const items: [KeyType, ValueType][] = [];
return this.store("readonly", (store) =>
this.eachCursor(store, (cursor) =>
items.push([cursor.key as KeyType, cursor.value])
).then(() => items)
);
});
}
}

View File

@@ -17,50 +17,49 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import localforage from "localforage"; import {
import { extendPrototype } from "@notesnook/localforage-getitems"; IndexedDBKVStore,
import * as MemoryDriver from "localforage-driver-memory"; LocalStorageKVStore,
MemoryKVStore,
IKVStore
} from "./key-value";
import { getNNCrypto } from "./nncrypto.stub"; import { getNNCrypto } from "./nncrypto.stub";
import type { Cipher, SerializedKey } from "@notesnook/crypto/dist/src/types"; import type { Cipher, SerializedKey } from "@notesnook/crypto/dist/src/types";
localforage.defineDriver(MemoryDriver);
extendPrototype(localforage);
type EncryptedKey = { iv: Uint8Array; cipher: BufferSource }; type EncryptedKey = { iv: Uint8Array; cipher: BufferSource };
export type DatabasePersistence = "memory" | "db"; export type DatabasePersistence = "memory" | "db";
const APP_SALT = "oVzKtazBo7d8sb7TBvY9jw"; const APP_SALT = "oVzKtazBo7d8sb7TBvY9jw";
export class NNStorage { export class NNStorage {
database: LocalForage; database: IKVStore;
constructor(name: string, persistence: DatabasePersistence = "db") { constructor(name: string, persistence: DatabasePersistence = "db") {
const drivers = this.database =
persistence === "memory" persistence === "memory"
? [MemoryDriver._driver] ? new MemoryKVStore()
: [localforage.INDEXEDDB, localforage.WEBSQL, localforage.LOCALSTORAGE]; : IndexedDBKVStore.isIndexedDBSupported()
this.database = localforage.createInstance({ ? new IndexedDBKVStore(name, "keyvaluepairs")
name, : new LocalStorageKVStore();
driver: drivers
});
} }
read<T>(key: string): Promise<T | null> { read<T>(key: string): Promise<T | undefined> {
if (!key) return Promise.resolve(null); if (!key) return Promise.resolve(undefined);
return this.database.getItem(key); return this.database.get(key);
} }
readMulti(keys: string[]) { readMulti(keys: string[]) {
if (keys.length <= 0) return []; if (keys.length <= 0) return [];
return this.database.getItems(keys.sort()); return this.database.getMany(keys.sort());
} }
write<T>(key: string, data: T) { write<T>(key: string, data: T) {
return this.database.setItem(key, data); return this.database.set(key, data);
} }
remove(key: string) { remove(key: string) {
return this.database.removeItem(key); return this.database.delete(key);
} }
clear() { clear() {
@@ -78,7 +77,11 @@ export class NNStorage {
const crypto = await getNNCrypto(); const crypto = await getNNCrypto();
const keyData = await crypto.exportKey(password, salt); const keyData = await crypto.exportKey(password, salt);
if (this.isIndexedDBSupported() && window?.crypto?.subtle && keyData.key) { if (
IndexedDBKVStore.isIndexedDBSupported() &&
window?.crypto?.subtle &&
keyData.key
) {
const pbkdfKey = await derivePBKDF2Key(password); const pbkdfKey = await derivePBKDF2Key(password);
await this.write(name, pbkdfKey); await this.write(name, pbkdfKey);
const cipheredKey = await aesEncrypt(pbkdfKey, keyData.key); const cipheredKey = await aesEncrypt(pbkdfKey, keyData.key);
@@ -91,7 +94,7 @@ export class NNStorage {
} }
async getCryptoKey(name: string): Promise<string | undefined> { async getCryptoKey(name: string): Promise<string | undefined> {
if (this.isIndexedDBSupported() && window?.crypto?.subtle) { if (IndexedDBKVStore.isIndexedDBSupported() && window?.crypto?.subtle) {
const pbkdfKey = await this.read<CryptoKey>(name); const pbkdfKey = await this.read<CryptoKey>(name);
const cipheredKey = await this.read<EncryptedKey | string>(`${name}@_k`); const cipheredKey = await this.read<EncryptedKey | string>(`${name}@_k`);
if (typeof cipheredKey === "string") return cipheredKey; if (typeof cipheredKey === "string") return cipheredKey;
@@ -104,10 +107,6 @@ export class NNStorage {
} }
} }
isIndexedDBSupported(): boolean {
return this.database.driver() === "asyncStorage";
}
async generateCryptoKey( async generateCryptoKey(
password: string, password: string,
salt?: string salt?: string

View File

@@ -29,12 +29,16 @@ function get<T>(key: string, def?: T): T {
return value ? tryParse(value) : def; return value ? tryParse(value) : def;
} }
function remove(key: string) {
window.localStorage.removeItem(key);
}
function clear() { function clear() {
window.localStorage.clear(); window.localStorage.clear();
} }
function all(): Record<string, unknown> { function all<T>(): Record<string, T> {
const data: Record<string, unknown> = {}; const data: Record<string, T> = {};
for (let i = 0; i < window.localStorage.length; ++i) { for (let i = 0; i < window.localStorage.length; ++i) {
const key = window.localStorage.key(i); const key = window.localStorage.key(i);
if (!key) continue; if (!key) continue;
@@ -52,5 +56,5 @@ function has(predicate: (key: string) => boolean) {
return false; return false;
} }
const Config = { set, get, clear, all, has }; const Config = { set, get, clear, all, has, remove };
export default Config; export default Config;