1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 07:13:32 +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

1
.github/CODEOWNERS vendored
View File

@@ -90,6 +90,7 @@ libs/common/src/platform @bitwarden/team-platform-dev
libs/common/spec @bitwarden/team-platform-dev
libs/common/src/state-migrations @bitwarden/team-platform-dev
libs/platform @bitwarden/team-platform-dev
libs/storage-core @bitwarden/team-platform-dev
# Web utils used across app and connectors
apps/web/src/utils/ @bitwarden/team-platform-dev
# Web core and shared files

View File

@@ -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<StorageUpdate>;
}
export abstract class AbstractStorageService {
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>;
}
export {
StorageUpdateType,
StorageUpdate,
ObservableStorageService,
AbstractStorageService,
} from "@bitwarden/storage-core";

View File

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

View File

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

View File

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

View File

@@ -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<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();
}
}
export { MemoryStorageService } from "@bitwarden/storage-core";

View File

@@ -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<ClientLocations>,
): [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";

View File

@@ -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.

View File

@@ -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<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();
}
}
export { SerializedMemoryStorageService as MemoryStorageService } from "@bitwarden/storage-core";

View File

@@ -0,0 +1,5 @@
# storage-core
Owned by: platform
Abstractions over storage APIs

View File

@@ -0,0 +1,3 @@
import baseConfig from "../../eslint.config.mjs";
export default [...baseConfig];

View File

@@ -0,0 +1,10 @@
module.exports = {
displayName: "storage-core",
preset: "../../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../../coverage/libs/storage-core",
};

View File

@@ -0,0 +1,11 @@
{
"name": "@bitwarden/storage-core",
"version": "0.0.1",
"description": "Abstractions over storage APIs",
"private": true,
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL-3.0",
"author": "platform"
}

View File

@@ -0,0 +1,33 @@
{
"name": "storage-core",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/storage-core/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/storage-core",
"main": "libs/storage-core/src/index.ts",
"tsConfig": "libs/storage-core/tsconfig.lib.json",
"assets": ["libs/storage-core/*.md"]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/storage-core/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/storage-core/jest.config.js"
}
}
}
}

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

@@ -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(() => {

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

@@ -1,12 +1,11 @@
import { mock } from "jest-mock-extended";
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
import { StorageServiceProvider } from "./storage-service.provider";
import { StorageService, ObservableStorageService } from "./storage.service";
describe("StorageServiceProvider", () => {
const mockDiskStorage = mock<AbstractStorageService & ObservableStorageService>();
const mockMemoryStorage = mock<AbstractStorageService & ObservableStorageService>();
const mockDiskStorage = mock<StorageService & ObservableStorageService>();
const mockMemoryStorage = mock<StorageService & ObservableStorageService>();
const sut = new StorageServiceProvider(mockDiskStorage, 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>;
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}

2666
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,7 @@
"@bitwarden/platform": ["./libs/platform/src"],
"@bitwarden/platform/*": ["./libs/platform/src/*"],
"@bitwarden/send-ui": ["./libs/tools/send/send-ui/src"],
"@bitwarden/storage-core": ["libs/storage-core/src/index.ts"],
"@bitwarden/ui-common": ["./libs/ui/common/src"],
"@bitwarden/ui-common/setup-jest": ["./libs/ui/common/src/setup-jest"],
"@bitwarden/vault": ["./libs/vault/src"],