From 676ef0908d0f36fb904a34d8a6866fbc4b7ba1e1 Mon Sep 17 00:00:00 2001 From: addisonbeck Date: Wed, 4 Jun 2025 12:53:24 -0400 Subject: [PATCH] refactor(storage-core): move storage files out of @bitwarden/common --- libs/common/spec/fake-storage.service.ts | 120 +----------------- .../platform/abstractions/storage.service.ts | 32 +---- .../enums/html-storage-location.enum.ts | 8 +- .../platform/enums/storage-location.enum.ts | 8 +- .../platform/models/domain/storage-options.ts | 10 +- .../services/memory-storage.service.ts | 48 +------ .../services/storage-service.provider.ts | 41 +----- .../src/platform/state/state-definition.ts | 44 +------ .../state/storage/memory-storage.service.ts | 55 +------- libs/storage-core/src/client-locations.ts | 31 +++++ libs/storage-core/src/fake-storage.service.ts | 115 +++++++++++++++++ .../src/html-storage-location.enum.ts | 7 + libs/storage-core/src/index.ts | 10 ++ .../src/memory-storage.service.ts | 47 +++++++ ...serialized-memory-storage.service.spec.ts} | 8 +- .../src/serialized-memory-storage.service.ts | 50 ++++++++ .../storage-core/src/storage-location.enum.ts | 7 + libs/storage-core/src/storage-location.ts | 12 ++ libs/storage-core/src/storage-options.ts | 10 ++ .../src}/storage-service.provider.spec.ts | 3 +- .../src/storage-service.provider.ts | 39 ++++++ libs/storage-core/src/storage.service.ts | 26 ++++ 22 files changed, 376 insertions(+), 355 deletions(-) create mode 100644 libs/storage-core/src/client-locations.ts create mode 100644 libs/storage-core/src/fake-storage.service.ts create mode 100644 libs/storage-core/src/html-storage-location.enum.ts create mode 100644 libs/storage-core/src/memory-storage.service.ts rename libs/{common/src/platform/state/storage/memory-storage.service.spec.ts => storage-core/src/serialized-memory-storage.service.spec.ts} (84%) create mode 100644 libs/storage-core/src/serialized-memory-storage.service.ts create mode 100644 libs/storage-core/src/storage-location.enum.ts create mode 100644 libs/storage-core/src/storage-location.ts create mode 100644 libs/storage-core/src/storage-options.ts rename libs/{common/src/platform/services => storage-core/src}/storage-service.provider.spec.ts (96%) create mode 100644 libs/storage-core/src/storage-service.provider.ts create mode 100644 libs/storage-core/src/storage.service.ts diff --git a/libs/common/spec/fake-storage.service.ts b/libs/common/spec/fake-storage.service.ts index c6d989c5abf..a7166dd7720 100644 --- a/libs/common/spec/fake-storage.service.ts +++ b/libs/common/spec/fake-storage.service.ts @@ -1,119 +1 @@ -import { MockProxy, mock } from "jest-mock-extended"; -import { Subject } from "rxjs"; - -import { - AbstractStorageService, - ObservableStorageService, - StorageUpdate, -} from "../src/platform/abstractions/storage.service"; -import { StorageOptions } from "../src/platform/models/domain/storage-options"; - -const INTERNAL_KEY = "__internal__"; - -export class FakeStorageService implements AbstractStorageService, ObservableStorageService { - private store: Record; - private updatesSubject = new Subject(); - private _valuesRequireDeserialization = false; - - /** - * Returns a mock of a {@see AbstractStorageService} for asserting the expected - * amount of calls. It is not recommended to use this to mock implementations as - * they are not respected. - */ - mock: MockProxy; - - constructor(initial?: Record) { - this.store = initial ?? {}; - this.mock = mock(); - } - - /** - * Updates the internal store for this fake implementation, this bypasses any mock calls - * or updates to the {@link updates$} observable. - * @param store - */ - internalUpdateStore(store: Record) { - this.store = store; - } - - get internalStore() { - return this.store; - } - - internalUpdateValuesRequireDeserialization(value: boolean) { - this._valuesRequireDeserialization = value; - } - - get valuesRequireDeserialization(): boolean { - return this._valuesRequireDeserialization; - } - - get updates$() { - return this.updatesSubject.asObservable(); - } - - get(key: string, options?: StorageOptions): Promise { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.mock.get(key, options); - const value = this.store[key] as T; - return Promise.resolve(value); - } - has(key: string, options?: StorageOptions): Promise { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.mock.has(key, options); - return Promise.resolve(this.store[key] != null); - } - async save(key: string, obj: T, options?: StorageOptions): Promise { - // These exceptions are copied from https://github.com/sindresorhus/conf/blob/608adb0c46fb1680ddbd9833043478367a64c120/source/index.ts#L193-L203 - // which is a library that is used by `ElectronStorageService`. We add them here to ensure that the behavior in our testing mirrors the real world. - if (typeof key !== "string" && typeof key !== "object") { - throw new TypeError( - `Expected \`key\` to be of type \`string\` or \`object\`, got ${typeof key}`, - ); - } - - // We don't throw this error because ElectronStorageService automatically detects this case - // and calls `delete()` instead of `set()`. - // if (typeof key !== "object" && obj === undefined) { - // throw new TypeError("Use `delete()` to clear values"); - // } - - if (this._containsReservedKey(key)) { - throw new TypeError( - `Please don't use the ${INTERNAL_KEY} key, as it's used to manage this module internal operations.`, - ); - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.mock.save(key, obj, options); - this.store[key] = obj; - this.updatesSubject.next({ key: key, updateType: "save" }); - } - remove(key: string, options?: StorageOptions): Promise { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.mock.remove(key, options); - delete this.store[key]; - this.updatesSubject.next({ key: key, updateType: "remove" }); - return Promise.resolve(); - } - - private _containsReservedKey(key: string | Partial): boolean { - if (typeof key === "object") { - const firsKey = Object.keys(key)[0]; - - if (firsKey === INTERNAL_KEY) { - return true; - } - } - - if (typeof key !== "string") { - return false; - } - - return false; - } -} +export { FakeStorageService } from "@bitwarden/storage-core"; diff --git a/libs/common/src/platform/abstractions/storage.service.ts b/libs/common/src/platform/abstractions/storage.service.ts index 390d71ae2ad..8ff733dd730 100644 --- a/libs/common/src/platform/abstractions/storage.service.ts +++ b/libs/common/src/platform/abstractions/storage.service.ts @@ -1,26 +1,6 @@ -import { Observable } from "rxjs"; - -import { StorageOptions } from "../models/domain/storage-options"; - -export type StorageUpdateType = "save" | "remove"; -export type StorageUpdate = { - key: string; - updateType: StorageUpdateType; -}; - -export interface ObservableStorageService { - /** - * Provides an {@link Observable} that represents a stream of updates that - * have happened in this storage service or in the storage this service provides - * an interface to. - */ - get updates$(): Observable; -} - -export abstract class AbstractStorageService { - abstract get valuesRequireDeserialization(): boolean; - abstract get(key: string, options?: StorageOptions): Promise; - abstract has(key: string, options?: StorageOptions): Promise; - abstract save(key: string, obj: T, options?: StorageOptions): Promise; - abstract remove(key: string, options?: StorageOptions): Promise; -} +export { + StorageUpdateType, + StorageUpdate, + ObservableStorageService, + AbstractStorageService, +} from "@bitwarden/storage-core"; diff --git a/libs/common/src/platform/enums/html-storage-location.enum.ts b/libs/common/src/platform/enums/html-storage-location.enum.ts index 1d018a72869..80dc2d3850b 100644 --- a/libs/common/src/platform/enums/html-storage-location.enum.ts +++ b/libs/common/src/platform/enums/html-storage-location.enum.ts @@ -1,7 +1 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum HtmlStorageLocation { - Local = "local", - Memory = "memory", - Session = "session", -} +export { HtmlStorageLocation } from "@bitwarden/storage-core"; diff --git a/libs/common/src/platform/enums/storage-location.enum.ts b/libs/common/src/platform/enums/storage-location.enum.ts index 9f6e22babec..a312ea00af1 100644 --- a/libs/common/src/platform/enums/storage-location.enum.ts +++ b/libs/common/src/platform/enums/storage-location.enum.ts @@ -1,7 +1 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum StorageLocation { - Both = "both", - Disk = "disk", - Memory = "memory", -} +export { StorageLocationEnum as StorageLocation } from "@bitwarden/storage-core"; diff --git a/libs/common/src/platform/models/domain/storage-options.ts b/libs/common/src/platform/models/domain/storage-options.ts index e27628b8502..adf498ed2ab 100644 --- a/libs/common/src/platform/models/domain/storage-options.ts +++ b/libs/common/src/platform/models/domain/storage-options.ts @@ -1,9 +1 @@ -import { HtmlStorageLocation, StorageLocation } from "../../enums"; - -export type StorageOptions = { - storageLocation?: StorageLocation; - useSecureStorage?: boolean; - userId?: string; - htmlStorageLocation?: HtmlStorageLocation; - keySuffix?: string; -}; +export type { StorageOptions } from "@bitwarden/storage-core"; diff --git a/libs/common/src/platform/services/memory-storage.service.ts b/libs/common/src/platform/services/memory-storage.service.ts index 52d38dec9bf..9d5bb98e73a 100644 --- a/libs/common/src/platform/services/memory-storage.service.ts +++ b/libs/common/src/platform/services/memory-storage.service.ts @@ -1,47 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Subject } from "rxjs"; - -import { AbstractStorageService, StorageUpdate } from "../abstractions/storage.service"; - -export class MemoryStorageService extends AbstractStorageService { - protected store = new Map(); - private updatesSubject = new Subject(); - - get valuesRequireDeserialization(): boolean { - return false; - } - get updates$() { - return this.updatesSubject.asObservable(); - } - - get(key: string): Promise { - if (this.store.has(key)) { - const obj = this.store.get(key); - return Promise.resolve(obj as T); - } - return Promise.resolve(null); - } - - async has(key: string): Promise { - return (await this.get(key)) != null; - } - - save(key: string, obj: T): Promise { - if (obj == null) { - return this.remove(key); - } - // TODO: Remove once foreground/background contexts are separated in browser - // Needed to ensure ownership of all memory by the context running the storage service - const toStore = structuredClone(obj); - this.store.set(key, toStore); - this.updatesSubject.next({ key, updateType: "save" }); - return Promise.resolve(); - } - - remove(key: string): Promise { - this.store.delete(key); - this.updatesSubject.next({ key, updateType: "remove" }); - return Promise.resolve(); - } -} +export { MemoryStorageService } from "@bitwarden/storage-core"; diff --git a/libs/common/src/platform/services/storage-service.provider.ts b/libs/common/src/platform/services/storage-service.provider.ts index c34487403ca..32f2a7afc5c 100644 --- a/libs/common/src/platform/services/storage-service.provider.ts +++ b/libs/common/src/platform/services/storage-service.provider.ts @@ -1,39 +1,2 @@ -import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service"; -// eslint-disable-next-line import/no-restricted-paths -import { ClientLocations, StorageLocation } from "../state/state-definition"; - -export type PossibleLocation = StorageLocation | ClientLocations[keyof ClientLocations]; - -/** - * A provider for getting client specific computed storage locations and services. - */ -export class StorageServiceProvider { - constructor( - protected readonly diskStorageService: AbstractStorageService & ObservableStorageService, - protected readonly memoryStorageService: AbstractStorageService & ObservableStorageService, - ) {} - - /** - * Computes the location and corresponding service for a given client. - * - * **NOTE** The default implementation does not respect client overrides and if clients - * have special overrides they are responsible for implementing this service. - * @param defaultLocation The default location to use if no client specific override is preferred. - * @param overrides Client specific overrides - * @returns The computed storage location and corresponding storage service to use to get/store state. - * @throws If there is no configured storage service for the given inputs. - */ - get( - defaultLocation: PossibleLocation, - overrides: Partial, - ): [location: PossibleLocation, service: AbstractStorageService & ObservableStorageService] { - switch (defaultLocation) { - case "disk": - return [defaultLocation, this.diskStorageService]; - case "memory": - return [defaultLocation, this.memoryStorageService]; - default: - throw new Error(`Unexpected location: ${defaultLocation}`); - } - } -} +export { StorageServiceProvider } from "@bitwarden/storage-core"; +export type { PossibleLocation } from "@bitwarden/storage-core"; diff --git a/libs/common/src/platform/state/state-definition.ts b/libs/common/src/platform/state/state-definition.ts index 3caa03c95a5..5e24146fbdd 100644 --- a/libs/common/src/platform/state/state-definition.ts +++ b/libs/common/src/platform/state/state-definition.ts @@ -1,45 +1,7 @@ -/** - * Default storage location options. - * - * `disk` generally means state that is accessible between restarts of the application, - * with the exception of the web client. In web this means `sessionStorage`. The data - * persists through refreshes of the page but not available once that tab is closed or - * from any other tabs. - * - * `memory` means that the information stored there goes away during application - * restarts. - */ -export type StorageLocation = "disk" | "memory"; +import { StorageLocation, ClientLocations } from "@bitwarden/storage-core"; -/** - * *Note*: The property names of this object should match exactly with the string values of the {@link ClientType} enum - */ -export type ClientLocations = { - /** - * Overriding storage location for the web client. - * - * Includes an extra storage location to store data in `localStorage` - * that is available from different tabs and after a tab has closed. - */ - web: StorageLocation | "disk-local"; - /** - * Overriding storage location for browser clients. - * - * `"memory-large-object"` is used to store non-countable objects in memory. This exists due to limited persistent memory available to browser extensions. - * - * `"disk-backup-local-storage"` is used to store object in both disk and in `localStorage`. Data is stored in both locations but is only retrieved - * from `localStorage` when a null-ish value is retrieved from disk first. - */ - browser: StorageLocation | "memory-large-object" | "disk-backup-local-storage"; - /** - * Overriding storage location for desktop clients. - */ - //desktop: StorageLocation; - /** - * Overriding storage location for CLI clients. - */ - //cli: StorageLocation; -}; +// To be removed once references are updated to point to @bitwarden/storage-core +export { StorageLocation, ClientLocations }; /** * Defines the base location and instruction of where this state is expected to be located. diff --git a/libs/common/src/platform/state/storage/memory-storage.service.ts b/libs/common/src/platform/state/storage/memory-storage.service.ts index df3fe615626..53810f11d22 100644 --- a/libs/common/src/platform/state/storage/memory-storage.service.ts +++ b/libs/common/src/platform/state/storage/memory-storage.service.ts @@ -1,54 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Subject } from "rxjs"; - -import { - AbstractStorageService, - ObservableStorageService, - StorageUpdate, -} from "../../abstractions/storage.service"; - -export class MemoryStorageService - extends AbstractStorageService - implements ObservableStorageService -{ - protected store: Record = {}; - private updatesSubject = new Subject(); - - get valuesRequireDeserialization(): boolean { - return true; - } - get updates$() { - return this.updatesSubject.asObservable(); - } - - get(key: string): Promise { - const json = this.store[key]; - if (json) { - const obj = JSON.parse(json as string); - return Promise.resolve(obj as T); - } - return Promise.resolve(null); - } - - async has(key: string): Promise { - return (await this.get(key)) != null; - } - - save(key: string, obj: T): Promise { - if (obj == null) { - return this.remove(key); - } - // TODO: Remove once foreground/background contexts are separated in browser - // Needed to ensure ownership of all memory by the context running the storage service - this.store[key] = JSON.stringify(obj); - this.updatesSubject.next({ key, updateType: "save" }); - return Promise.resolve(); - } - - remove(key: string): Promise { - delete this.store[key]; - this.updatesSubject.next({ key, updateType: "remove" }); - return Promise.resolve(); - } -} +export { SerializedMemoryStorageService as MemoryStorageService } from "@bitwarden/storage-core"; diff --git a/libs/storage-core/src/client-locations.ts b/libs/storage-core/src/client-locations.ts new file mode 100644 index 00000000000..6ae8462fc31 --- /dev/null +++ b/libs/storage-core/src/client-locations.ts @@ -0,0 +1,31 @@ +import { StorageLocation } from "./storage-location"; + +/** + * *Note*: The property names of this object should match exactly with the string values of the {@link ClientType} enum + */ +export type ClientLocations = { + /** + * Overriding storage location for the web client. + * + * Includes an extra storage location to store data in `localStorage` + * that is available from different tabs and after a tab has closed. + */ + web: StorageLocation | "disk-local"; + /** + * Overriding storage location for browser clients. + * + * `"memory-large-object"` is used to store non-countable objects in memory. This exists due to limited persistent memory available to browser extensions. + * + * `"disk-backup-local-storage"` is used to store object in both disk and in `localStorage`. Data is stored in both locations but is only retrieved + * from `localStorage` when a null-ish value is retrieved from disk first. + */ + browser: StorageLocation | "memory-large-object" | "disk-backup-local-storage"; + /** + * Overriding storage location for desktop clients. + */ + //desktop: StorageLocation; + /** + * Overriding storage location for CLI clients. + */ + //cli: StorageLocation; +}; diff --git a/libs/storage-core/src/fake-storage.service.ts b/libs/storage-core/src/fake-storage.service.ts new file mode 100644 index 00000000000..37abad400a3 --- /dev/null +++ b/libs/storage-core/src/fake-storage.service.ts @@ -0,0 +1,115 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { Subject } from "rxjs"; + +import { StorageOptions } from "./storage-options"; +import { AbstractStorageService, ObservableStorageService, StorageUpdate } from "./storage.service"; + +const INTERNAL_KEY = "__internal__"; + +export class FakeStorageService implements AbstractStorageService, ObservableStorageService { + private store: Record; + private updatesSubject = new Subject(); + private _valuesRequireDeserialization = false; + + /** + * Returns a mock of a {@see AbstractStorageService} for asserting the expected + * amount of calls. It is not recommended to use this to mock implementations as + * they are not respected. + */ + mock: MockProxy; + + constructor(initial?: Record) { + this.store = initial ?? {}; + this.mock = mock(); + } + + /** + * Updates the internal store for this fake implementation, this bypasses any mock calls + * or updates to the {@link updates$} observable. + * @param store + */ + internalUpdateStore(store: Record) { + this.store = store; + } + + get internalStore() { + return this.store; + } + + internalUpdateValuesRequireDeserialization(value: boolean) { + this._valuesRequireDeserialization = value; + } + + get valuesRequireDeserialization(): boolean { + return this._valuesRequireDeserialization; + } + + get updates$() { + return this.updatesSubject.asObservable(); + } + + get(key: string, options?: StorageOptions): Promise { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.mock.get(key, options); + const value = this.store[key] as T; + return Promise.resolve(value); + } + has(key: string, options?: StorageOptions): Promise { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.mock.has(key, options); + return Promise.resolve(this.store[key] != null); + } + async save(key: string, obj: T, options?: StorageOptions): Promise { + // These exceptions are copied from https://github.com/sindresorhus/conf/blob/608adb0c46fb1680ddbd9833043478367a64c120/source/index.ts#L193-L203 + // which is a library that is used by `ElectronStorageService`. We add them here to ensure that the behavior in our testing mirrors the real world. + if (typeof key !== "string" && typeof key !== "object") { + throw new TypeError( + `Expected \`key\` to be of type \`string\` or \`object\`, got ${typeof key}`, + ); + } + + // We don't throw this error because ElectronStorageService automatically detects this case + // and calls `delete()` instead of `set()`. + // if (typeof key !== "object" && obj === undefined) { + // throw new TypeError("Use `delete()` to clear values"); + // } + + if (this._containsReservedKey(key)) { + throw new TypeError( + `Please don't use the ${INTERNAL_KEY} key, as it's used to manage this module internal operations.`, + ); + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.mock.save(key, obj, options); + this.store[key] = obj; + this.updatesSubject.next({ key: key, updateType: "save" }); + } + remove(key: string, options?: StorageOptions): Promise { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.mock.remove(key, options); + delete this.store[key]; + this.updatesSubject.next({ key: key, updateType: "remove" }); + return Promise.resolve(); + } + + private _containsReservedKey(key: string | Partial): boolean { + if (typeof key === "object") { + const firsKey = Object.keys(key)[0]; + + if (firsKey === INTERNAL_KEY) { + return true; + } + } + + if (typeof key !== "string") { + return false; + } + + return false; + } +} diff --git a/libs/storage-core/src/html-storage-location.enum.ts b/libs/storage-core/src/html-storage-location.enum.ts new file mode 100644 index 00000000000..1d018a72869 --- /dev/null +++ b/libs/storage-core/src/html-storage-location.enum.ts @@ -0,0 +1,7 @@ +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum HtmlStorageLocation { + Local = "local", + Memory = "memory", + Session = "session", +} diff --git a/libs/storage-core/src/index.ts b/libs/storage-core/src/index.ts index e69de29bb2d..570d8bcdec9 100644 --- a/libs/storage-core/src/index.ts +++ b/libs/storage-core/src/index.ts @@ -0,0 +1,10 @@ +export * from "./client-locations"; +export * from "./fake-storage.service"; +export * from "./html-storage-location.enum"; +export * from "./memory-storage.service"; +export * from "./serialized-memory-storage.service"; +export * from "./storage-location"; +export * from "./storage-location.enum"; +export * from "./storage-options"; +export * from "./storage-service.provider"; +export * from "./storage.service"; diff --git a/libs/storage-core/src/memory-storage.service.ts b/libs/storage-core/src/memory-storage.service.ts new file mode 100644 index 00000000000..c8583d9e576 --- /dev/null +++ b/libs/storage-core/src/memory-storage.service.ts @@ -0,0 +1,47 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Subject } from "rxjs"; + +import { AbstractStorageService, StorageUpdate } from "./storage.service"; + +export class MemoryStorageService extends AbstractStorageService { + protected store = new Map(); + private updatesSubject = new Subject(); + + get valuesRequireDeserialization(): boolean { + return false; + } + get updates$() { + return this.updatesSubject.asObservable(); + } + + get(key: string): Promise { + if (this.store.has(key)) { + const obj = this.store.get(key); + return Promise.resolve(obj as T); + } + return Promise.resolve(null); + } + + async has(key: string): Promise { + return (await this.get(key)) != null; + } + + save(key: string, obj: T): Promise { + if (obj == null) { + return this.remove(key); + } + // TODO: Remove once foreground/background contexts are separated in browser + // Needed to ensure ownership of all memory by the context running the storage service + const toStore = structuredClone(obj); + this.store.set(key, toStore); + this.updatesSubject.next({ key, updateType: "save" }); + return Promise.resolve(); + } + + remove(key: string): Promise { + this.store.delete(key); + this.updatesSubject.next({ key, updateType: "remove" }); + return Promise.resolve(); + } +} diff --git a/libs/common/src/platform/state/storage/memory-storage.service.spec.ts b/libs/storage-core/src/serialized-memory-storage.service.spec.ts similarity index 84% rename from libs/common/src/platform/state/storage/memory-storage.service.spec.ts rename to libs/storage-core/src/serialized-memory-storage.service.spec.ts index 419934ffdf9..3f51a84494e 100644 --- a/libs/common/src/platform/state/storage/memory-storage.service.spec.ts +++ b/libs/storage-core/src/serialized-memory-storage.service.spec.ts @@ -1,12 +1,12 @@ -import { MemoryStorageService } from "./memory-storage.service"; +import { SerializedMemoryStorageService } from "./serialized-memory-storage.service"; -describe("MemoryStorageService", () => { - let sut: MemoryStorageService; +describe("SerializedMemoryStorageService", () => { + let sut: SerializedMemoryStorageService; const key = "key"; const value = { test: "value" }; beforeEach(() => { - sut = new MemoryStorageService(); + sut = new SerializedMemoryStorageService(); }); afterEach(() => { diff --git a/libs/storage-core/src/serialized-memory-storage.service.ts b/libs/storage-core/src/serialized-memory-storage.service.ts new file mode 100644 index 00000000000..35066f5c8c6 --- /dev/null +++ b/libs/storage-core/src/serialized-memory-storage.service.ts @@ -0,0 +1,50 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Subject } from "rxjs"; + +import { AbstractStorageService, ObservableStorageService, StorageUpdate } from "./storage.service"; + +export class SerializedMemoryStorageService + extends AbstractStorageService + implements ObservableStorageService +{ + protected store: Record = {}; + private updatesSubject = new Subject(); + + get valuesRequireDeserialization(): boolean { + return true; + } + get updates$() { + return this.updatesSubject.asObservable(); + } + + get(key: string): Promise { + const json = this.store[key]; + if (json) { + const obj = JSON.parse(json as string); + return Promise.resolve(obj as T); + } + return Promise.resolve(null); + } + + async has(key: string): Promise { + return (await this.get(key)) != null; + } + + save(key: string, obj: T): Promise { + if (obj == null) { + return this.remove(key); + } + // TODO: Remove once foreground/background contexts are separated in browser + // Needed to ensure ownership of all memory by the context running the storage service + this.store[key] = JSON.stringify(obj); + this.updatesSubject.next({ key, updateType: "save" }); + return Promise.resolve(); + } + + remove(key: string): Promise { + delete this.store[key]; + this.updatesSubject.next({ key, updateType: "remove" }); + return Promise.resolve(); + } +} diff --git a/libs/storage-core/src/storage-location.enum.ts b/libs/storage-core/src/storage-location.enum.ts new file mode 100644 index 00000000000..3ee8926ad59 --- /dev/null +++ b/libs/storage-core/src/storage-location.enum.ts @@ -0,0 +1,7 @@ +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum StorageLocationEnum { + Both = "both", + Disk = "disk", + Memory = "memory", +} diff --git a/libs/storage-core/src/storage-location.ts b/libs/storage-core/src/storage-location.ts new file mode 100644 index 00000000000..c3ad76860fc --- /dev/null +++ b/libs/storage-core/src/storage-location.ts @@ -0,0 +1,12 @@ +/** + * Default storage location options. + * + * `disk` generally means state that is accessible between restarts of the application, + * with the exception of the web client. In web this means `sessionStorage`. The data + * persists through refreshes of the page but not available once that tab is closed or + * from any other tabs. + * + * `memory` means that the information stored there goes away during application + * restarts. + */ +export type StorageLocation = "disk" | "memory"; diff --git a/libs/storage-core/src/storage-options.ts b/libs/storage-core/src/storage-options.ts new file mode 100644 index 00000000000..16c888e8037 --- /dev/null +++ b/libs/storage-core/src/storage-options.ts @@ -0,0 +1,10 @@ +import { HtmlStorageLocation } from "./html-storage-location.enum"; +import { StorageLocationEnum as StorageLocation } from "./storage-location.enum"; + +export type StorageOptions = { + storageLocation?: StorageLocation; + useSecureStorage?: boolean; + userId?: string; + htmlStorageLocation?: HtmlStorageLocation; + keySuffix?: string; +}; diff --git a/libs/common/src/platform/services/storage-service.provider.spec.ts b/libs/storage-core/src/storage-service.provider.spec.ts similarity index 96% rename from libs/common/src/platform/services/storage-service.provider.spec.ts rename to libs/storage-core/src/storage-service.provider.spec.ts index 35f45064d49..243a4ba4094 100644 --- a/libs/common/src/platform/services/storage-service.provider.spec.ts +++ b/libs/storage-core/src/storage-service.provider.spec.ts @@ -1,8 +1,7 @@ import { mock } from "jest-mock-extended"; -import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service"; - import { StorageServiceProvider } from "./storage-service.provider"; +import { AbstractStorageService, ObservableStorageService } from "./storage.service"; describe("StorageServiceProvider", () => { const mockDiskStorage = mock(); diff --git a/libs/storage-core/src/storage-service.provider.ts b/libs/storage-core/src/storage-service.provider.ts new file mode 100644 index 00000000000..a819acc487d --- /dev/null +++ b/libs/storage-core/src/storage-service.provider.ts @@ -0,0 +1,39 @@ +import { ClientLocations } from "./client-locations"; +import { StorageLocation } from "./storage-location"; +import { AbstractStorageService, ObservableStorageService } from "./storage.service"; + +export type PossibleLocation = StorageLocation | ClientLocations[keyof ClientLocations]; + +/** + * A provider for getting client specific computed storage locations and services. + */ +export class StorageServiceProvider { + constructor( + protected readonly diskStorageService: AbstractStorageService & ObservableStorageService, + protected readonly memoryStorageService: AbstractStorageService & ObservableStorageService, + ) {} + + /** + * Computes the location and corresponding service for a given client. + * + * **NOTE** The default implementation does not respect client overrides and if clients + * have special overrides they are responsible for implementing this service. + * @param defaultLocation The default location to use if no client specific override is preferred. + * @param overrides Client specific overrides + * @returns The computed storage location and corresponding storage service to use to get/store state. + * @throws If there is no configured storage service for the given inputs. + */ + get( + defaultLocation: PossibleLocation, + overrides: Partial, + ): [location: PossibleLocation, service: AbstractStorageService & ObservableStorageService] { + switch (defaultLocation) { + case "disk": + return [defaultLocation, this.diskStorageService]; + case "memory": + return [defaultLocation, this.memoryStorageService]; + default: + throw new Error(`Unexpected location: ${defaultLocation}`); + } + } +} diff --git a/libs/storage-core/src/storage.service.ts b/libs/storage-core/src/storage.service.ts new file mode 100644 index 00000000000..316c6c94dc4 --- /dev/null +++ b/libs/storage-core/src/storage.service.ts @@ -0,0 +1,26 @@ +import { Observable } from "rxjs"; + +import { StorageOptions } from "./storage-options"; + +export type StorageUpdateType = "save" | "remove"; +export type StorageUpdate = { + key: string; + updateType: StorageUpdateType; +}; + +export interface ObservableStorageService { + /** + * Provides an {@link Observable} that represents a stream of updates that + * have happened in this storage service or in the storage this service provides + * an interface to. + */ + get updates$(): Observable; +} + +export abstract class AbstractStorageService { + abstract get valuesRequireDeserialization(): boolean; + abstract get(key: string, options?: StorageOptions): Promise; + abstract has(key: string, options?: StorageOptions): Promise; + abstract save(key: string, obj: T, options?: StorageOptions): Promise; + abstract remove(key: string, options?: StorageOptions): Promise; +}