1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-31 23:53:37 +00:00

refactor(storage-core): move storage files out of @bitwarden/common (#15076)

* refactor(platform): generate @bitwarden/storage-core boilerplate

* refactor(storage-core): move storage files out of @bitwarden/common

* chore(naming): rename AbstractStorageService to StorageService
This commit is contained in:
Addison Beck
2025-06-23 16:00:54 -04:00
committed by GitHub
parent 5bd4d1691e
commit 95841eb078
32 changed files with 1918 additions and 1354 deletions

View File

@@ -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;
};

View File

@@ -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",
}

View File

@@ -0,0 +1,11 @@
export * from "./client-locations";
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";
// Renamed to just "StorageService", to be removed when references are updated
export { StorageService as AbstractStorageService } from "./storage.service";
export * from "./storage.service";

View File

@@ -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 { StorageService, StorageUpdate } from "./storage.service";
export class MemoryStorageService extends StorageService {
protected store = new Map<string, unknown>();
private updatesSubject = new Subject<StorageUpdate>();
get valuesRequireDeserialization(): boolean {
return false;
}
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string): Promise<T> {
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<boolean> {
return (await this.get(key)) != null;
}
save<T>(key: string, obj: T): Promise<void> {
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<void> {
this.store.delete(key);
this.updatesSubject.next({ key, updateType: "remove" });
return Promise.resolve();
}
}

View File

@@ -0,0 +1,53 @@
import { SerializedMemoryStorageService } from "./serialized-memory-storage.service";
describe("SerializedMemoryStorageService", () => {
let sut: SerializedMemoryStorageService;
const key = "key";
const value = { test: "value" };
beforeEach(() => {
sut = new SerializedMemoryStorageService();
});
afterEach(() => {
jest.resetAllMocks();
});
describe("get", () => {
it("should return null if the key does not exist", async () => {
const result = await sut.get(key);
expect(result).toBeNull();
});
it("should return the value if the key exists", async () => {
await sut.save(key, value);
const result = await sut.get(key);
expect(result).toEqual(value);
});
it("should json parse stored values", async () => {
sut["store"][key] = JSON.stringify({ test: "value" });
const result = await sut.get(key);
expect(result).toEqual({ test: "value" });
});
});
describe("save", () => {
it("should store the value as json string", async () => {
const value = { test: "value" };
await sut.save(key, value);
expect(sut["store"][key]).toEqual(JSON.stringify(value));
});
});
describe("remove", () => {
it("should remove a value from store", async () => {
await sut.save(key, value);
await sut.remove(key);
expect(Object.keys(sut["store"])).not.toContain(key);
});
});
});

View File

@@ -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 { StorageService, ObservableStorageService, StorageUpdate } from "./storage.service";
export class SerializedMemoryStorageService
extends StorageService
implements ObservableStorageService
{
protected store: Record<string, string> = {};
private updatesSubject = new Subject<StorageUpdate>();
get valuesRequireDeserialization(): boolean {
return true;
}
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string): Promise<T> {
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<boolean> {
return (await this.get(key)) != null;
}
save<T>(key: string, obj: T): Promise<void> {
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<void> {
delete this.store[key];
this.updatesSubject.next({ key, updateType: "remove" });
return Promise.resolve();
}
}

View File

@@ -0,0 +1,8 @@
import * as lib from "./index";
describe("storage-core", () => {
// This test will fail until something is exported from index.ts
it("should work", () => {
expect(lib).toBeDefined();
});
});

View File

@@ -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",
}

View File

@@ -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";

View File

@@ -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;
};

View File

@@ -0,0 +1,27 @@
import { mock } from "jest-mock-extended";
import { StorageServiceProvider } from "./storage-service.provider";
import { StorageService, ObservableStorageService } from "./storage.service";
describe("StorageServiceProvider", () => {
const mockDiskStorage = mock<StorageService & ObservableStorageService>();
const mockMemoryStorage = mock<StorageService & ObservableStorageService>();
const sut = new StorageServiceProvider(mockDiskStorage, mockMemoryStorage);
describe("get", () => {
it("gets disk service when default location is disk", () => {
const [computedLocation, computedService] = sut.get("disk", {});
expect(computedLocation).toBe("disk");
expect(computedService).toStrictEqual(mockDiskStorage);
});
it("gets memory service when default location is memory", () => {
const [computedLocation, computedService] = sut.get("memory", {});
expect(computedLocation).toBe("memory");
expect(computedService).toStrictEqual(mockMemoryStorage);
});
});
});

View File

@@ -0,0 +1,39 @@
import { ClientLocations } from "./client-locations";
import { StorageLocation } from "./storage-location";
import { StorageService, 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: StorageService & ObservableStorageService,
protected readonly memoryStorageService: StorageService & 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<ClientLocations>,
): [location: PossibleLocation, service: StorageService & ObservableStorageService] {
switch (defaultLocation) {
case "disk":
return [defaultLocation, this.diskStorageService];
case "memory":
return [defaultLocation, this.memoryStorageService];
default:
throw new Error(`Unexpected location: ${defaultLocation}`);
}
}
}

View File

@@ -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<StorageUpdate>;
}
export abstract class StorageService {
abstract get valuesRequireDeserialization(): boolean;
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
abstract has(key: string, options?: StorageOptions): Promise<boolean>;
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
abstract remove(key: string, options?: StorageOptions): Promise<void>;
}