mirror of
https://github.com/logos-messaging/js-waku.git
synced 2026-01-04 06:43:12 +00:00
feat: shard retrieval for store and store peers selection (#2417)
* feat: implement shard retrieval for store and improve set store peers usage * remove log * remove only, improve condition * implement smarter way to retrieve peers * up tests * update mock * address nits, add target to eslint, revert to es2022
This commit is contained in:
parent
fcc6496fef
commit
f55db3eb4b
@ -4,7 +4,6 @@ import {
|
|||||||
type Peer,
|
type Peer,
|
||||||
type PeerId,
|
type PeerId,
|
||||||
type PeerInfo,
|
type PeerInfo,
|
||||||
type PeerStore,
|
|
||||||
type Stream,
|
type Stream,
|
||||||
TypedEventEmitter
|
TypedEventEmitter
|
||||||
} from "@libp2p/interface";
|
} from "@libp2p/interface";
|
||||||
@ -574,12 +573,9 @@ export class ConnectionManager
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSameShard = await this.isPeerTopicConfigured(peerId);
|
const isSameShard = await this.isPeerOnSameShard(peerId);
|
||||||
if (!isSameShard) {
|
if (!isSameShard) {
|
||||||
const shardInfo = await this.getPeerShardInfo(
|
const shardInfo = await this.getPeerShardInfo(peerId);
|
||||||
peerId,
|
|
||||||
this.libp2p.peerStore
|
|
||||||
);
|
|
||||||
|
|
||||||
log.warn(
|
log.warn(
|
||||||
`Discovered peer ${peerId.toString()} with ShardInfo ${shardInfo} is not part of any of the configured pubsub topics (${
|
`Discovered peer ${peerId.toString()} with ShardInfo ${shardInfo} is not part of any of the configured pubsub topics (${
|
||||||
@ -666,28 +662,40 @@ export class ConnectionManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isPeerTopicConfigured(peerId: PeerId): Promise<boolean> {
|
public async isPeerOnSameShard(peerId: PeerId): Promise<boolean> {
|
||||||
const shardInfo = await this.getPeerShardInfo(
|
const shardInfo = await this.getPeerShardInfo(peerId);
|
||||||
peerId,
|
|
||||||
this.libp2p.peerStore
|
|
||||||
);
|
|
||||||
|
|
||||||
// If there's no shard information, simply return true
|
if (!shardInfo) {
|
||||||
if (!shardInfo) return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const pubsubTopics = shardInfoToPubsubTopics(shardInfo);
|
const pubsubTopics = shardInfoToPubsubTopics(shardInfo);
|
||||||
|
|
||||||
const isTopicConfigured = pubsubTopics.some((topic) =>
|
const isTopicConfigured = pubsubTopics.some((topic) =>
|
||||||
this.pubsubTopics.includes(topic)
|
this.pubsubTopics.includes(topic)
|
||||||
);
|
);
|
||||||
|
|
||||||
return isTopicConfigured;
|
return isTopicConfigured;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPeerShardInfo(
|
public async isPeerOnPubsubTopic(
|
||||||
peerId: PeerId,
|
peerId: PeerId,
|
||||||
peerStore: PeerStore
|
pubsubTopic: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const shardInfo = await this.getPeerShardInfo(peerId);
|
||||||
|
|
||||||
|
if (!shardInfo) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubsubTopics = shardInfoToPubsubTopics(shardInfo);
|
||||||
|
return pubsubTopics.some((t) => t === pubsubTopic);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPeerShardInfo(
|
||||||
|
peerId: PeerId
|
||||||
): Promise<ShardInfo | undefined> {
|
): Promise<ShardInfo | undefined> {
|
||||||
const peer = await peerStore.get(peerId);
|
const peer = await this.libp2p.peerStore.get(peerId);
|
||||||
const shardInfoBytes = peer.metadata.get("shardInfo");
|
const shardInfoBytes = peer.metadata.get("shardInfo");
|
||||||
if (!shardInfoBytes) return undefined;
|
if (!shardInfoBytes) return undefined;
|
||||||
return decodeRelayShard(shardInfoBytes);
|
return decodeRelayShard(shardInfoBytes);
|
||||||
|
|||||||
@ -99,5 +99,9 @@ export type IStore = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type StoreProtocolOptions = {
|
export type StoreProtocolOptions = {
|
||||||
peer: string;
|
/**
|
||||||
|
* List of Multi-addresses of peers to be prioritized for Store protocol queries.
|
||||||
|
* @default []
|
||||||
|
*/
|
||||||
|
peers: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -106,8 +106,13 @@ export async function createLibp2pAndUpdateOptions(
|
|||||||
peerDiscovery.push(...getPeerDiscoveries(options.discovery));
|
peerDiscovery.push(...getPeerDiscoveries(options.discovery));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.bootstrapPeers) {
|
const bootstrapPeers = [
|
||||||
peerDiscovery.push(bootstrap({ list: options.bootstrapPeers }));
|
...(options.bootstrapPeers || []),
|
||||||
|
...(options.store?.peers || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
if (bootstrapPeers.length) {
|
||||||
|
peerDiscovery.push(bootstrap({ list: bootstrapPeers }));
|
||||||
}
|
}
|
||||||
|
|
||||||
libp2pOptions.peerDiscovery = peerDiscovery;
|
libp2pOptions.peerDiscovery = peerDiscovery;
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import type { PeerId } from "@libp2p/interface";
|
import type { Peer, PeerId } from "@libp2p/interface";
|
||||||
import { ConnectionManager, messageHash, StoreCore } from "@waku/core";
|
import { peerIdFromString } from "@libp2p/peer-id";
|
||||||
|
import { multiaddr } from "@multiformats/multiaddr";
|
||||||
|
import {
|
||||||
|
ConnectionManager,
|
||||||
|
messageHash,
|
||||||
|
StoreCodec,
|
||||||
|
StoreCore
|
||||||
|
} from "@waku/core";
|
||||||
import {
|
import {
|
||||||
IDecodedMessage,
|
IDecodedMessage,
|
||||||
IDecoder,
|
IDecoder,
|
||||||
@ -28,14 +35,14 @@ type StoreConstructorParams = {
|
|||||||
*/
|
*/
|
||||||
export class Store implements IStore {
|
export class Store implements IStore {
|
||||||
private readonly options: Partial<StoreProtocolOptions>;
|
private readonly options: Partial<StoreProtocolOptions>;
|
||||||
private readonly peerManager: PeerManager;
|
private readonly libp2p: Libp2p;
|
||||||
private readonly connectionManager: ConnectionManager;
|
private readonly connectionManager: ConnectionManager;
|
||||||
private readonly protocol: StoreCore;
|
private readonly protocol: StoreCore;
|
||||||
|
|
||||||
public constructor(params: StoreConstructorParams) {
|
public constructor(params: StoreConstructorParams) {
|
||||||
this.options = params.options || {};
|
this.options = params.options || {};
|
||||||
this.peerManager = params.peerManager;
|
|
||||||
this.connectionManager = params.connectionManager;
|
this.connectionManager = params.connectionManager;
|
||||||
|
this.libp2p = params.libp2p;
|
||||||
|
|
||||||
this.protocol = new StoreCore(
|
this.protocol = new StoreCore(
|
||||||
params.connectionManager.pubsubTopics,
|
params.connectionManager.pubsubTopics,
|
||||||
@ -93,7 +100,7 @@ export class Store implements IStore {
|
|||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
const peer = await this.getPeerToUse();
|
const peer = await this.getPeerToUse(pubsubTopic);
|
||||||
|
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
log.error("No peers available to query");
|
log.error("No peers available to query");
|
||||||
@ -260,32 +267,81 @@ export class Store implements IStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPeerToUse(): Promise<PeerId | undefined> {
|
private async getPeerToUse(pubsubTopic: string): Promise<PeerId | undefined> {
|
||||||
let peerId: PeerId | undefined;
|
const peers = await this.filterConnectedPeers(pubsubTopic);
|
||||||
|
|
||||||
if (this.options?.peer) {
|
const peer = this.options.peers
|
||||||
const connectedPeers = await this.connectionManager.getConnectedPeers();
|
? await this.getPeerFromConfigurationOrFirst(peers, this.options.peers)
|
||||||
|
: peers[0]?.id;
|
||||||
|
|
||||||
const peer = connectedPeers.find(
|
return peer;
|
||||||
(p) => p.id.toString() === this.options?.peer
|
}
|
||||||
|
|
||||||
|
private async getPeerFromConfigurationOrFirst(
|
||||||
|
peers: Peer[],
|
||||||
|
configPeers: string[]
|
||||||
|
): Promise<PeerId | undefined> {
|
||||||
|
const storeConfigPeers = configPeers.map(multiaddr);
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
for (const peer of storeConfigPeers) {
|
||||||
|
const matchedPeer = peers.find(
|
||||||
|
(p) => p.id.toString() === peer.getPeerId()?.toString()
|
||||||
);
|
);
|
||||||
peerId = peer?.id;
|
|
||||||
|
|
||||||
if (!peerId) {
|
if (matchedPeer) {
|
||||||
|
return matchedPeer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
missing.push(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (missing.length) {
|
||||||
|
const toDial = missing.pop();
|
||||||
|
|
||||||
|
if (!toDial) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conn = await this.libp2p.dial(toDial);
|
||||||
|
|
||||||
|
if (conn) {
|
||||||
|
return peerIdFromString(toDial.getPeerId() as string);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`Passed node to use for Store not found: ${this.options.peer}. Attempting to use random peers.`
|
`Failed to dial peer from options.peers list for Store protocol. Peer:${toDial.getPeerId()}, error:${e}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const peerIds = this.peerManager.getPeers();
|
log.warn(
|
||||||
|
`Passed node to use for Store not found: ${configPeers.toString()}. Attempting to use first available peers.`
|
||||||
|
);
|
||||||
|
|
||||||
if (peerIds.length > 0) {
|
return peers[0]?.id;
|
||||||
// TODO(weboko): implement smart way of getting a peer https://github.com/waku-org/js-waku/issues/2243
|
}
|
||||||
return peerIds[Math.floor(Math.random() * peerIds.length)];
|
|
||||||
|
private async filterConnectedPeers(pubsubTopic: string): Promise<Peer[]> {
|
||||||
|
const peers = await this.connectionManager.getConnectedPeers();
|
||||||
|
const result: Peer[] = [];
|
||||||
|
|
||||||
|
for (const peer of peers) {
|
||||||
|
const isStoreCodec = peer.protocols.includes(StoreCodec);
|
||||||
|
const isSameShard = await this.connectionManager.isPeerOnSameShard(
|
||||||
|
peer.id
|
||||||
|
);
|
||||||
|
const isSamePubsub = await this.connectionManager.isPeerOnPubsubTopic(
|
||||||
|
peer.id,
|
||||||
|
pubsubTopic
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isStoreCodec && isSameShard && isSamePubsub) {
|
||||||
|
result.push(peer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.error("No peers available to use.");
|
return result;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
import type { Peer, PeerId, Stream } from "@libp2p/interface";
|
import type { Peer, PeerId, Stream } from "@libp2p/interface";
|
||||||
import { MultiaddrInput } from "@multiformats/multiaddr";
|
import { MultiaddrInput } from "@multiformats/multiaddr";
|
||||||
import {
|
import { ConnectionManager, createDecoder, createEncoder } from "@waku/core";
|
||||||
ConnectionManager,
|
|
||||||
createDecoder,
|
|
||||||
createEncoder,
|
|
||||||
StoreCodec
|
|
||||||
} from "@waku/core";
|
|
||||||
import type {
|
import type {
|
||||||
CreateDecoderParams,
|
CreateDecoderParams,
|
||||||
CreateEncoderParams,
|
CreateEncoderParams,
|
||||||
@ -103,21 +98,11 @@ export class WakuNode implements IWaku {
|
|||||||
this.health = new HealthIndicator({ libp2p });
|
this.health = new HealthIndicator({ libp2p });
|
||||||
|
|
||||||
if (protocolsEnabled.store) {
|
if (protocolsEnabled.store) {
|
||||||
if (options.store?.peer) {
|
|
||||||
this.connectionManager
|
|
||||||
.rawDialPeerWithProtocols(options.store.peer, [StoreCodec])
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("Failed to dial store peer", e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.store = new Store({
|
this.store = new Store({
|
||||||
libp2p,
|
libp2p,
|
||||||
connectionManager: this.connectionManager,
|
connectionManager: this.connectionManager,
|
||||||
peerManager: this.peerManager,
|
peerManager: this.peerManager,
|
||||||
options: {
|
options: options?.store
|
||||||
peer: options.store?.peer
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,21 +17,21 @@ describe("Dials", function () {
|
|||||||
let dialPeerStub: SinonStub;
|
let dialPeerStub: SinonStub;
|
||||||
let getConnectionsStub: SinonStub;
|
let getConnectionsStub: SinonStub;
|
||||||
let getTagNamesForPeerStub: SinonStub;
|
let getTagNamesForPeerStub: SinonStub;
|
||||||
let isPeerTopicConfigured: SinonStub;
|
let isPeerOnSameShard: SinonStub;
|
||||||
let waku: LightNode;
|
let waku: LightNode;
|
||||||
|
|
||||||
beforeEachCustom(this, async () => {
|
beforeEachCustom(this, async () => {
|
||||||
waku = await createLightNode();
|
waku = await createLightNode();
|
||||||
isPeerTopicConfigured = sinon.stub(
|
isPeerOnSameShard = sinon.stub(
|
||||||
waku.connectionManager as any,
|
waku.connectionManager as any,
|
||||||
"isPeerTopicConfigured"
|
"isPeerOnSameShard"
|
||||||
);
|
);
|
||||||
isPeerTopicConfigured.resolves(true);
|
isPeerOnSameShard.resolves(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEachCustom(this, async () => {
|
afterEachCustom(this, async () => {
|
||||||
await tearDownNodes([], waku);
|
await tearDownNodes([], waku);
|
||||||
isPeerTopicConfigured.restore();
|
isPeerOnSameShard.restore();
|
||||||
sinon.restore();
|
sinon.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ describe("multiaddr: dialing", function () {
|
|||||||
let waku: IWaku;
|
let waku: IWaku;
|
||||||
let nwaku: ServiceNode;
|
let nwaku: ServiceNode;
|
||||||
let dialPeerSpy: SinonSpy;
|
let dialPeerSpy: SinonSpy;
|
||||||
let isPeerTopicConfigured: SinonStub;
|
let isPeerOnSameShard: SinonStub;
|
||||||
|
|
||||||
afterEachCustom(this, async () => {
|
afterEachCustom(this, async () => {
|
||||||
await tearDownNodes(nwaku, waku);
|
await tearDownNodes(nwaku, waku);
|
||||||
@ -63,11 +63,11 @@ describe("multiaddr: dialing", function () {
|
|||||||
peerId = await nwaku.getPeerId();
|
peerId = await nwaku.getPeerId();
|
||||||
multiaddr = await nwaku.getMultiaddrWithId();
|
multiaddr = await nwaku.getMultiaddrWithId();
|
||||||
|
|
||||||
isPeerTopicConfigured = Sinon.stub(
|
isPeerOnSameShard = Sinon.stub(
|
||||||
waku.connectionManager as any,
|
waku.connectionManager as any,
|
||||||
"isPeerTopicConfigured"
|
"isPeerOnSameShard"
|
||||||
);
|
);
|
||||||
isPeerTopicConfigured.resolves(true);
|
isPeerOnSameShard.resolves(true);
|
||||||
dialPeerSpy = Sinon.spy(waku.connectionManager as any, "dialPeer");
|
dialPeerSpy = Sinon.spy(waku.connectionManager as any, "dialPeer");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -304,13 +304,10 @@ describe("Waku Store, general", function () {
|
|||||||
for await (const msg of query) {
|
for await (const msg of query) {
|
||||||
if (msg) {
|
if (msg) {
|
||||||
messages.push(msg as DecodedMessage);
|
messages.push(msg as DecodedMessage);
|
||||||
console.log(bytesToUtf8(msg.payload!));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(messages.length);
|
|
||||||
|
|
||||||
// Messages are ordered from oldest to latest within a page (1 page query)
|
// Messages are ordered from oldest to latest within a page (1 page query)
|
||||||
expect(bytesToUtf8(messages[0].payload!)).to.eq(asymText);
|
expect(bytesToUtf8(messages[0].payload!)).to.eq(asymText);
|
||||||
expect(bytesToUtf8(messages[1].payload!)).to.eq(symText);
|
expect(bytesToUtf8(messages[1].payload!)).to.eq(symText);
|
||||||
|
|||||||
@ -105,15 +105,25 @@ describe("Waku Store, custom pubsub topic", function () {
|
|||||||
it("Generator, 2 nwaku nodes each with different pubsubtopics", async function () {
|
it("Generator, 2 nwaku nodes each with different pubsubtopics", async function () {
|
||||||
this.timeout(10000);
|
this.timeout(10000);
|
||||||
|
|
||||||
|
await tearDownNodes([nwaku], []);
|
||||||
|
|
||||||
|
// make sure each nwaku node operates on dedicated shard only
|
||||||
|
nwaku = new ServiceNode(makeLogFileName(this) + "1");
|
||||||
|
await nwaku.start({
|
||||||
|
store: true,
|
||||||
|
clusterId: TestShardInfo.clusterId,
|
||||||
|
shard: [TestShardInfo.shards[0]],
|
||||||
|
relay: true
|
||||||
|
});
|
||||||
|
|
||||||
// Set up and start a new nwaku node with Default Pubsubtopic
|
// Set up and start a new nwaku node with Default Pubsubtopic
|
||||||
nwaku2 = new ServiceNode(makeLogFileName(this) + "2");
|
nwaku2 = new ServiceNode(makeLogFileName(this) + "2");
|
||||||
await nwaku2.start({
|
await nwaku2.start({
|
||||||
store: true,
|
store: true,
|
||||||
clusterId: TestShardInfo.clusterId,
|
clusterId: TestShardInfo.clusterId,
|
||||||
shard: TestShardInfo.shards,
|
shard: [TestShardInfo.shards[1]],
|
||||||
relay: true
|
relay: true
|
||||||
});
|
});
|
||||||
await nwaku2.ensureSubscriptions([TestDecoder2.pubsubTopic]);
|
|
||||||
|
|
||||||
const totalMsgs = 10;
|
const totalMsgs = 10;
|
||||||
await sendMessages(
|
await sendMessages(
|
||||||
@ -129,6 +139,7 @@ describe("Waku Store, custom pubsub topic", function () {
|
|||||||
TestDecoder2.pubsubTopic
|
TestDecoder2.pubsubTopic
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await waku.dial(await nwaku.getMultiaddrWithId());
|
||||||
await waku.dial(await nwaku2.getMultiaddrWithId());
|
await waku.dial(await nwaku2.getMultiaddrWithId());
|
||||||
await waku.waitForPeers([Protocols.Store]);
|
await waku.waitForPeers([Protocols.Store]);
|
||||||
|
|
||||||
@ -366,6 +377,17 @@ describe("Waku Store (named sharding), custom pubsub topic", function () {
|
|||||||
it("Generator, 2 nwaku nodes each with different pubsubtopics", async function () {
|
it("Generator, 2 nwaku nodes each with different pubsubtopics", async function () {
|
||||||
this.timeout(10000);
|
this.timeout(10000);
|
||||||
|
|
||||||
|
await tearDownNodes([nwaku], []);
|
||||||
|
|
||||||
|
// make sure each nwaku node operates on dedicated shard only
|
||||||
|
nwaku = new ServiceNode(makeLogFileName(this) + "1");
|
||||||
|
await nwaku.start({
|
||||||
|
store: true,
|
||||||
|
clusterId: TestShardInfo.clusterId,
|
||||||
|
shard: [TestShardInfo.shards[0]],
|
||||||
|
relay: true
|
||||||
|
});
|
||||||
|
|
||||||
// Set up and start a new nwaku node with Default Pubsubtopic
|
// Set up and start a new nwaku node with Default Pubsubtopic
|
||||||
nwaku2 = new ServiceNode(makeLogFileName(this) + "2");
|
nwaku2 = new ServiceNode(makeLogFileName(this) + "2");
|
||||||
await nwaku2.start({
|
await nwaku2.start({
|
||||||
@ -390,6 +412,7 @@ describe("Waku Store (named sharding), custom pubsub topic", function () {
|
|||||||
TestDecoder2.pubsubTopic
|
TestDecoder2.pubsubTopic
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await waku.dial(await nwaku.getMultiaddrWithId());
|
||||||
await waku.dial(await nwaku2.getMultiaddrWithId());
|
await waku.dial(await nwaku2.getMultiaddrWithId());
|
||||||
await waku.waitForPeers([Protocols.Store]);
|
await waku.waitForPeers([Protocols.Store]);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"target": "ES2023",
|
"target": "ES2022",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
@ -38,7 +38,7 @@
|
|||||||
// "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
|
// "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
|
||||||
// "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
|
// "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
|
||||||
|
|
||||||
"lib": ["es2023", "dom"],
|
"lib": ["es2022", "dom"],
|
||||||
"types": ["node", "mocha"],
|
"types": ["node", "mocha"],
|
||||||
"typeRoots": ["node_modules/@types"]
|
"typeRoots": ["node_modules/@types"]
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user