feat: local provider

This commit is contained in:
DarkSky
2023-01-02 18:55:53 +08:00
committed by DarkSky
parent b01703b836
commit 7fea77b64f
14 changed files with 464 additions and 142 deletions

View File

@@ -31,19 +31,18 @@ export const useEnsureWorkspace = () => {
return; return;
} }
// If user is not login and input a custom workspaceId, jump to 404 page // If user is not login and input a custom workspaceId, jump to 404 page
if ( // if (
!user && // !user &&
router.query.workspaceId && // router.query.workspaceId &&
router.query.workspaceId !== defaultOutLineWorkspaceId // router.query.workspaceId !== defaultOutLineWorkspaceId
) { // ) {
debugger; // router.push('/404');
router.push('/404'); // return;
return; // }
}
const workspaceId = user const workspaceId = user
? (router.query.workspaceId as string) || workspacesMeta?.[0]?.id ? (router.query.workspaceId as string) || workspacesMeta[0]?.id
: defaultOutLineWorkspaceId; : (router.query.workspaceId as string) || defaultOutLineWorkspaceId;
loadWorkspace(workspaceId).finally(() => { loadWorkspace(workspaceId).finally(() => {
setWorkspaceLoaded(true); setWorkspaceLoaded(true);

View File

@@ -21,12 +21,14 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.29.1", "@playwright/test": "^1.29.1",
"@types/debug": "^4.1.7",
"fake-indexeddb": "4.0.1", "fake-indexeddb": "4.0.1",
"typescript": "^4.8.4" "typescript": "^4.8.4"
}, },
"dependencies": { "dependencies": {
"@blocksuite/blocks": "0.3.0-20221228162706-9576a3a", "@blocksuite/blocks": "0.3.0-20221228162706-9576a3a",
"@blocksuite/store": "0.3.0-20221228162706-9576a3a", "@blocksuite/store": "0.3.0-20221228162706-9576a3a",
"debug": "^4.3.4",
"encoding": "^0.1.13", "encoding": "^0.1.13",
"firebase": "^9.15.0", "firebase": "^9.15.0",
"idb-keyval": "^6.2.0", "idb-keyval": "^6.2.0",

View File

@@ -2,32 +2,52 @@ import assert from 'assert';
import { BlockSchema } from '@blocksuite/blocks/models'; import { BlockSchema } from '@blocksuite/blocks/models';
import { Workspace } from '@blocksuite/store'; import { Workspace } from '@blocksuite/store';
import { getLogger } from './index.js';
import { AffineProvider, BaseProvider } from './provider/index.js'; import { AffineProvider, BaseProvider } from './provider/index.js';
import { MemoryProvider } from './provider/index.js'; import { LocalProvider } from './provider/index.js';
import { getKVConfigure } from './store.js'; import { getKVConfigure } from './store.js';
export class DataCenter { export class DataCenter {
private readonly _providers = new Map<string, typeof BaseProvider>(); private readonly _providers = new Map<string, typeof BaseProvider>();
private readonly _workspaces = new Map<string, Promise<BaseProvider>>(); private readonly _workspaces = new Map<string, Promise<BaseProvider>>();
private readonly _config; private readonly _config;
private readonly _logger;
static async init(): Promise<DataCenter> { static async init(): Promise<DataCenter> {
const dc = new DataCenter(); const dc = new DataCenter();
dc.addProvider(AffineProvider); dc.addProvider(AffineProvider);
dc.addProvider(MemoryProvider); dc.addProvider(LocalProvider);
return dc; return dc;
} }
private constructor() { private constructor() {
this._config = getKVConfigure('sys'); this._config = getKVConfigure('sys');
this._logger = getLogger('dc');
this._logger.enabled = true;
} }
addProvider(provider: typeof BaseProvider) { private addProvider(provider: typeof BaseProvider) {
this._providers.set(provider.id, provider); this._providers.set(provider.id, provider);
} }
private async _initWithProvider(id: string, providerId: string) { private async _getProvider(id: string, providerId: string): Promise<string> {
const providerKey = `workspace:${id}:provider`;
if (this._providers.has(providerId)) {
await this._config.set(providerKey, providerId);
return providerId;
} else {
const providerValue = await this._config.get(providerKey);
if (providerValue) return providerValue;
}
throw Error(`Provider ${providerId} not found`);
}
private async _initWorkspace(id: string, pid: string): Promise<BaseProvider> {
this._logger(`Init workspace ${id} with ${pid}`);
const providerId = await this._getProvider(id, pid);
// init workspace & register block schema // init workspace & register block schema
const workspace = new Workspace({ room: id }).register(BlockSchema); const workspace = new Workspace({ room: id }).register(BlockSchema);
@@ -35,38 +55,22 @@ export class DataCenter {
assert(Provider); assert(Provider);
const provider = new Provider(); const provider = new Provider();
console.log(`Loading workspace ${id} with provider ${Provider.id}`); await provider.init({
await provider.init(getKVConfigure(id), workspace); config: getKVConfigure(id),
logger: this._logger.extend(`${Provider.id}:${id}`),
workspace,
});
await provider.initData(); await provider.initData();
console.log(`Workspace ${id} loaded`); this._logger(`Workspace ${id} loaded`);
return provider; return provider;
} }
private async _initWorkspace(
id: string,
providerId: string
): Promise<BaseProvider> {
const providerKey = `workspace:${id}:provider`;
const providerValue = await this._config.get(providerKey);
if (this._providers.has(providerValue || providerId)) {
if (!providerValue) {
await this._config.set(providerKey, providerId);
}
return this._initWithProvider(id, await this._config.get(providerKey));
} else {
throw Error(`provider ${providerId} not found`);
}
}
async initWorkspace( async initWorkspace(
id: string, id: string,
provider = 'memory' provider = 'local'
): Promise<Workspace | null> { ): Promise<Workspace | null> {
if (id) { if (id) {
console.log('initWorkspace', id);
if (!this._workspaces.has(id)) { if (!this._workspaces.has(id)) {
this._workspaces.set(id, this._initWorkspace(id, provider)); this._workspaces.set(id, this._initWorkspace(id, provider));
} }
@@ -81,4 +85,11 @@ export class DataCenter {
const config = getKVConfigure(workspace); const config = getKVConfigure(workspace);
return config.set(key, value); return config.set(key, value);
} }
async getWorkspaceList() {
const keys = await this._config.keys();
return keys
.filter(k => k.startsWith('workspace:'))
.map(k => k.split(':')[1]);
}
} }

View File

@@ -1,3 +1,4 @@
import debug from 'debug';
import { DataCenter } from './datacenter.js'; import { DataCenter } from './datacenter.js';
const _initializeDataCenter = () => { const _initializeDataCenter = () => {
@@ -13,3 +14,9 @@ const _initializeDataCenter = () => {
}; };
export const getDataCenter = _initializeDataCenter(); export const getDataCenter = _initializeDataCenter();
export function getLogger(namespace: string) {
const logger = debug(namespace);
logger.log = console.log.bind(console);
return logger;
}

View File

@@ -1 +1,79 @@
export { AffineProvider } from './provider.js'; import assert from 'assert';
import { applyUpdate } from 'yjs';
import type { InitialParams } from '../index.js';
import { LocalProvider } from '../local/index.js';
import { downloadWorkspace } from './apis.js';
import { token, Callback } from './token.js';
export class AffineProvider extends LocalProvider {
static id = 'affine';
private _onTokenRefresh?: Callback = undefined;
constructor() {
super();
}
async init(params: InitialParams) {
super.init(params);
this._onTokenRefresh = () => {
if (token.refresh) {
this._config.set('token', token.refresh);
}
};
assert(this._onTokenRefresh);
token.onChange(this._onTokenRefresh);
// initial login token
if (token.isExpired) {
try {
const refreshToken = await this._config.get('token');
await token.refreshToken(refreshToken);
if (token.refresh) {
this._config.set('token', token.refresh);
}
assert(token.isLogin);
} catch (_) {
this._logger('Authorization failed, fallback to local mode');
}
} else {
this._config.set('token', token.refresh);
}
}
async destroy() {
if (this._onTokenRefresh) {
token.offChange(this._onTokenRefresh);
}
}
async initData() {
await super.initData();
const workspace = this._workspace;
const doc = workspace.doc;
this._logger(`Login: ${token.isLogin}`);
if (workspace.room && token.isLogin) {
try {
const updates = await downloadWorkspace(workspace.room);
if (updates) {
applyUpdate(doc, new Uint8Array(updates));
}
} catch (e) {
this._logger('Failed to init cloud workspace', e);
}
}
// if after update, the space:meta is empty
// then we need to get map with doc
// just a workaround for yjs
doc.getMap('space:meta');
}
}

View File

@@ -1,74 +0,0 @@
import assert from 'assert';
import { Workspace } from '@blocksuite/store';
import { BaseProvider, ConfigStore } from '../index.js';
import { downloadWorkspace } from './apis.js';
import { token, Callback } from './token.js';
export class AffineProvider extends BaseProvider {
static id = 'affine';
private _onTokenRefresh?: Callback = undefined;
constructor() {
super();
}
async init(config: ConfigStore, workspace: Workspace) {
super.init(config, workspace);
this._onTokenRefresh = () => {
if (token.refresh) {
this._config.set('token', token.refresh);
}
};
assert(this._onTokenRefresh);
token.onChange(this._onTokenRefresh);
// initial login token
if (token.isExpired) {
try {
const refreshToken = await this._config.get('token');
await token.refreshToken(refreshToken);
if (token.refresh) {
this._config.set('token', token.refresh);
}
assert(token.isLogin);
} catch (_) {
console.warn('authorization failed, fallback to local mode');
}
} else {
this._config.set('token', token.refresh);
}
}
async destroy() {
if (this._onTokenRefresh) {
token.offChange(this._onTokenRefresh);
}
}
async initData() {
const workspace = this._workspace;
const doc = workspace.doc;
console.log(workspace.room, token.isLogin);
if (workspace.room && token.isLogin) {
try {
const updates = await downloadWorkspace(workspace.room);
if (updates) {
Workspace.Y.applyUpdate(doc, new Uint8Array(updates));
}
} catch (e) {
console.warn('Failed to init cloud workspace', e);
}
}
// if after update, the space:meta is empty
// then we need to get map with doc
// just a workaround for yjs
doc.getMap('space:meta');
}
}

View File

@@ -1,3 +1,4 @@
import { getLogger } from '../../index.js';
import { bareClient } from './request.js'; import { bareClient } from './request.js';
export interface AccessTokenMessage { export interface AccessTokenMessage {
@@ -27,6 +28,7 @@ const login = (params: LoginParams): Promise<LoginResponse> =>
bareClient.post('api/user/token', { json: params }).json(); bareClient.post('api/user/token', { json: params }).json();
class Token { class Token {
private readonly _logger;
private _accessToken!: string; private _accessToken!: string;
private _refreshToken!: string; private _refreshToken!: string;
@@ -34,17 +36,22 @@ class Token {
private _padding?: Promise<LoginResponse>; private _padding?: Promise<LoginResponse>;
constructor() { constructor() {
this._logger = getLogger('token');
this._logger.enabled = true;
this._setToken(); // fill with default value this._setToken(); // fill with default value
} }
private _setToken(login?: LoginResponse) { private _setToken(login?: LoginResponse) {
console.log('set login', login);
this._accessToken = login?.token || ''; this._accessToken = login?.token || '';
this._refreshToken = login?.refresh || ''; this._refreshToken = login?.refresh || '';
this._user = Token.parse(this._accessToken); this._user = Token.parse(this._accessToken);
if (login) { if (login) {
this._logger('set login', login);
this.triggerChange(this._user); this.triggerChange(this._user);
} else {
this._logger('empty login');
} }
} }

View File

@@ -1,19 +1,23 @@
import { Workspace } from '@blocksuite/store'; /* eslint-disable @typescript-eslint/no-unused-vars */
import type { Workspace } from '@blocksuite/store';
import type { ConfigStore } from '../store.js'; import type { Logger, InitialParams, ConfigStore } from './index';
export class BaseProvider { export class BaseProvider {
static id = 'memory'; static id = 'base';
protected _config!: ConfigStore; protected _config!: ConfigStore;
protected _logger!: Logger;
protected _workspace!: Workspace; protected _workspace!: Workspace;
constructor() { constructor() {
// Nothing to do here // Nothing to do here
} }
async init(config: ConfigStore, workspace: Workspace) { async init(params: InitialParams) {
this._config = config; this._config = params.config;
this._workspace = workspace; this._logger = params.logger;
this._workspace = params.workspace;
this._logger.enabled = true;
} }
async destroy() { async destroy() {
@@ -24,6 +28,16 @@ export class BaseProvider {
throw Error('Not implemented: initData'); throw Error('Not implemented: initData');
} }
// should return a blob url
async getBlob(_id: string): Promise<string | null> {
throw Error('Not implemented: getBlob');
}
// should return a blob unique id
async setBlob(_blob: Blob): Promise<string> {
throw Error('Not implemented: setBlob');
}
get workspace() { get workspace() {
return this._workspace; return this._workspace;
} }

View File

@@ -1,5 +1,17 @@
export type { ConfigStore } from '../store'; import type { Workspace } from '@blocksuite/store';
export { BaseProvider } from './base.js'; import type { getLogger } from '../index';
import type { ConfigStore } from '../store';
export type Logger = ReturnType<typeof getLogger>;
export type InitialParams = {
config: ConfigStore;
logger: Logger;
workspace: Workspace;
};
export type { ConfigStore, Workspace };
export type { BaseProvider } from './base.js';
export { AffineProvider } from './affine/index.js'; export { AffineProvider } from './affine/index.js';
export { MemoryProvider } from './memory.js'; export { LocalProvider } from './local/index.js';

View File

@@ -0,0 +1,50 @@
import type { BlobStorage } from '@blocksuite/store';
import assert from 'assert';
import type { InitialParams } from '../index.js';
import { BaseProvider } from '../base.js';
import { IndexedDBProvider } from './indexeddb.js';
export class LocalProvider extends BaseProvider {
static id = 'local';
private _blobs!: BlobStorage;
private _idb?: IndexedDBProvider = undefined;
constructor() {
super();
}
async init(params: InitialParams) {
super.init(params);
const blobs = await this._workspace.blobs;
assert(blobs);
this._blobs = blobs;
}
async initData() {
assert(this._workspace.room);
this._logger('Loading local data');
this._idb = new IndexedDBProvider(
this._workspace.room,
this._workspace.doc
);
await this._idb.whenSynced;
this._logger('Local data loaded');
}
async destroy(): Promise<void> {
super.destroy();
if (this._idb) {
await this._idb.destroy();
}
}
async getBlob(id: string): Promise<string | null> {
return this._blobs.get(id);
}
async setBlob(blob: Blob): Promise<string> {
return this._blobs.set(blob);
}
}

View File

@@ -0,0 +1,199 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as idb from 'lib0/indexeddb.js';
import { Observable } from 'lib0/observable.js';
import type { Doc } from 'yjs';
import { applyUpdate, encodeStateAsUpdate, transact } from 'yjs';
const customStoreName = 'custom';
const updatesStoreName = 'updates';
const PREFERRED_TRIM_SIZE = 500;
const fetchUpdates = async (provider: IndexedDBProvider) => {
const [updatesStore] = idb.transact(provider.db as IDBDatabase, [
updatesStoreName,
]); // , 'readonly')
if (updatesStore) {
const updates = await idb.getAll(
updatesStore,
idb.createIDBKeyRangeLowerBound(provider._dbref, false)
);
transact(
provider.doc,
() => {
updates.forEach(val => applyUpdate(provider.doc, val));
},
provider,
false
);
const lastKey = await idb.getLastKey(updatesStore);
provider._dbref = lastKey + 1;
const cnt = await idb.count(updatesStore);
provider._dbsize = cnt;
}
return updatesStore;
};
const storeState = (provider: IndexedDBProvider, forceStore = true) =>
fetchUpdates(provider).then(updatesStore => {
if (
updatesStore &&
(forceStore || provider._dbsize >= PREFERRED_TRIM_SIZE)
) {
idb
.addAutoKey(updatesStore, encodeStateAsUpdate(provider.doc))
.then(() =>
idb.del(
updatesStore,
idb.createIDBKeyRangeUpperBound(provider._dbref, true)
)
)
.then(() =>
idb.count(updatesStore).then(cnt => {
provider._dbsize = cnt;
})
);
}
});
export class IndexedDBProvider extends Observable<string> {
doc: Doc;
name: string;
_dbref: number;
_dbsize: number;
private _destroyed: boolean;
whenSynced: Promise<IndexedDBProvider>;
db: IDBDatabase | null;
private _db: Promise<IDBDatabase>;
private _storeTimeout: number;
private _storeTimeoutId: NodeJS.Timeout | null;
private _storeUpdate: (update: Uint8Array, origin: any) => void;
constructor(name: string, doc: Doc) {
super();
this.doc = doc;
this.name = name;
this._dbref = 0;
this._dbsize = 0;
this._destroyed = false;
this.db = null;
this._db = idb.openDB(name, db =>
idb.createStores(db, [['updates', { autoIncrement: true }], ['custom']])
);
this.whenSynced = this._db.then(async db => {
this.db = db;
const currState = encodeStateAsUpdate(doc);
const updatesStore = await fetchUpdates(this);
if (updatesStore) {
await idb.addAutoKey(updatesStore, currState);
}
if (this._destroyed) {
return this;
}
this.emit('synced', [this]);
return this;
});
// Timeout in ms untill data is merged and persisted in idb.
this._storeTimeout = 1000;
this._storeTimeoutId = null;
this._storeUpdate = (update: Uint8Array, origin: any) => {
if (this.db && origin !== this) {
const [updatesStore] = idb.transact(
/** @type {IDBDatabase} */ this.db,
[updatesStoreName]
);
if (updatesStore) {
idb.addAutoKey(updatesStore, update);
}
if (++this._dbsize >= PREFERRED_TRIM_SIZE) {
// debounce store call
if (this._storeTimeoutId !== null) {
clearTimeout(this._storeTimeoutId);
}
this._storeTimeoutId = setTimeout(() => {
storeState(this, false);
this._storeTimeoutId = null;
}, this._storeTimeout);
}
}
};
doc.on('update', this._storeUpdate);
this.destroy = this.destroy.bind(this);
doc.on('destroy', this.destroy);
}
override destroy() {
if (this._storeTimeoutId) {
clearTimeout(this._storeTimeoutId);
}
this.doc.off('update', this._storeUpdate);
this.doc.off('destroy', this.destroy);
this._destroyed = true;
return this._db.then(db => {
db.close();
});
}
/**
* Destroys this instance and removes all data from indexeddb.
*
* @return {Promise<void>}
*/
async clearData(): Promise<void> {
return this.destroy().then(() => {
idb.deleteDB(this.name);
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @return {Promise<String | number | ArrayBuffer | Date | any>}
*/
async get(
key: string | number | ArrayBuffer | Date
): Promise<string | number | ArrayBuffer | Date | any> {
return this._db.then(db => {
const [custom] = idb.transact(db, [customStoreName], 'readonly');
if (custom) {
return idb.get(custom, key);
}
return undefined;
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @param {String | number | ArrayBuffer | Date} value
* @return {Promise<String | number | ArrayBuffer | Date>}
*/
async set(
key: string | number | ArrayBuffer | Date,
value: string | number | ArrayBuffer | Date
): Promise<string | number | ArrayBuffer | Date> {
return this._db.then(db => {
const [custom] = idb.transact(db, [customStoreName]);
if (custom) {
return idb.put(custom, value, key);
}
return undefined;
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @return {Promise<undefined>}
*/
async del(key: string | number | ArrayBuffer | Date): Promise<undefined> {
return this._db.then(db => {
const [custom] = idb.transact(db, [customStoreName]);
if (custom) {
return idb.del(custom, key);
}
return undefined;
});
}
}

View File

@@ -1,11 +0,0 @@
import { BaseProvider } from './base.js';
export class MemoryProvider extends BaseProvider {
constructor() {
super();
}
async initData() {
console.log('Skip data reload in memory provider');
}
}

View File

@@ -4,7 +4,7 @@ import { getDataCenter } from './utils.js';
import 'fake-indexeddb/auto'; import 'fake-indexeddb/auto';
test('can init data center', async () => { test('init data center', async () => {
const dataCenter = await getDataCenter(); const dataCenter = await getDataCenter();
expect(dataCenter).toBeTruthy(); expect(dataCenter).toBeTruthy();
@@ -12,24 +12,38 @@ test('can init data center', async () => {
expect(workspace).toBeTruthy(); expect(workspace).toBeTruthy();
}); });
test('should init error', async () => { test('should init error with unknown provider', async () => {
const dataCenter = await getDataCenter(); const dataCenter = await getDataCenter();
test.fail(); test.fail();
await dataCenter.initWorkspace('test', 'not exist provider'); await dataCenter.initWorkspace('test', 'not exist provider');
}); });
test('can init affine provider', async () => { test.skip('init affine provider', async () => {
const dataCenter = await getDataCenter(); const dataCenter = await getDataCenter();
// TODO: set constant token for testing // TODO: set constant token for testing
await dataCenter.setWorkspaceConfig( await dataCenter.setWorkspaceConfig('6', 'token', 'YOUR_TOKEN');
'6',
'token',
'Zzq338Py_3veZwD4XTa0nyoDGsLqhd9nFeaT1p_SK43TAOCSkcV63Tn3kDUWfBI4JHKqX7mhED4IFejm_62KUpGXRWZv11c5BGay7Nhvb_br'
);
const workspace = await dataCenter.initWorkspace('6', 'affine'); const workspace = await dataCenter.initWorkspace('6', 'affine');
expect(workspace).toBeTruthy(); expect(workspace).toBeTruthy();
}); });
test('list workspaces', async () => {
const dataCenter = await getDataCenter();
await Promise.all([
dataCenter.initWorkspace('test1'),
dataCenter.initWorkspace('test2'),
dataCenter.initWorkspace('test3'),
dataCenter.initWorkspace('test4'),
]);
expect(await dataCenter.getWorkspaceList()).toStrictEqual([
'test1',
'test2',
'test3',
'test4',
]);
});

14
pnpm-lock.yaml generated
View File

@@ -134,6 +134,8 @@ importers:
'@blocksuite/blocks': 0.3.0-20221228162706-9576a3a '@blocksuite/blocks': 0.3.0-20221228162706-9576a3a
'@blocksuite/store': 0.3.0-20221228162706-9576a3a '@blocksuite/store': 0.3.0-20221228162706-9576a3a
'@playwright/test': ^1.29.1 '@playwright/test': ^1.29.1
'@types/debug': ^4.1.7
debug: ^4.3.4
encoding: ^0.1.13 encoding: ^0.1.13
fake-indexeddb: 4.0.1 fake-indexeddb: 4.0.1
firebase: ^9.15.0 firebase: ^9.15.0
@@ -148,6 +150,7 @@ importers:
dependencies: dependencies:
'@blocksuite/blocks': 0.3.0-20221228162706-9576a3a_yjs@13.5.44 '@blocksuite/blocks': 0.3.0-20221228162706-9576a3a_yjs@13.5.44
'@blocksuite/store': 0.3.0-20221228162706-9576a3a_yjs@13.5.44 '@blocksuite/store': 0.3.0-20221228162706-9576a3a_yjs@13.5.44
debug: 4.3.4
encoding: 0.1.13 encoding: 0.1.13
firebase: 9.15.0_encoding@0.1.13 firebase: 9.15.0_encoding@0.1.13
idb-keyval: 6.2.0 idb-keyval: 6.2.0
@@ -159,6 +162,7 @@ importers:
yjs: 13.5.44 yjs: 13.5.44
devDependencies: devDependencies:
'@playwright/test': 1.29.1 '@playwright/test': 1.29.1
'@types/debug': 4.1.7
fake-indexeddb: 4.0.1 fake-indexeddb: 4.0.1
typescript: 4.9.3 typescript: 4.9.3
@@ -3623,6 +3627,12 @@ packages:
'@babel/types': 7.20.5 '@babel/types': 7.20.5
dev: true dev: true
/@types/debug/4.1.7:
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
dependencies:
'@types/ms': 0.7.31
dev: true
/@types/eslint/8.4.10: /@types/eslint/8.4.10:
resolution: {integrity: sha512-Sl/HOqN8NKPmhWo2VBEPm0nvHnu2LL3v9vKo8MEq0EtbJ4eVzGPl41VNPvn5E1i5poMk4/XD8UriLHpJvEP/Nw==} resolution: {integrity: sha512-Sl/HOqN8NKPmhWo2VBEPm0nvHnu2LL3v9vKo8MEq0EtbJ4eVzGPl41VNPvn5E1i5poMk4/XD8UriLHpJvEP/Nw==}
dependencies: dependencies:
@@ -3697,6 +3707,10 @@ packages:
resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==}
dev: true dev: true
/@types/ms/0.7.31:
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
dev: true
/@types/node/12.20.55: /@types/node/12.20.55:
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
dev: true dev: true