@steve02081504/fount-p2p 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/core/bytes_codec.mjs +51 -0
- package/core/canonical_json.mjs +21 -0
- package/core/composite_key.mjs +91 -0
- package/core/constants.mjs +53 -0
- package/core/entity_id.mjs +53 -0
- package/core/entity_id_parse.mjs +40 -0
- package/core/evfs_logical_path.mjs +15 -0
- package/core/hexIds.mjs +73 -0
- package/core/hlc.mjs +114 -0
- package/core/random_id.mjs +9 -0
- package/crypto/channel.mjs +101 -0
- package/crypto/checkpoint_sign.mjs +67 -0
- package/crypto/crypto.mjs +158 -0
- package/crypto/key.mjs +431 -0
- package/dag/canonicalize_row.mjs +65 -0
- package/dag/event_query.mjs +15 -0
- package/dag/index.mjs +335 -0
- package/dag/storage.mjs +333 -0
- package/dag/strip_extensions.mjs +18 -0
- package/dag/tunables.json +6 -0
- package/discovery/bt.mjs +260 -0
- package/discovery/index.mjs +134 -0
- package/discovery/mdns.mjs +138 -0
- package/discovery/nostr.mjs +273 -0
- package/entity/files/acl.mjs +38 -0
- package/entity/files/evfs.mjs +189 -0
- package/entity/files/evfs_ref.mjs +37 -0
- package/entity/files/manifest_acl_registry.mjs +93 -0
- package/entity/files/replica_host_cache.mjs +46 -0
- package/entity/files/url.mjs +19 -0
- package/entity/hosting_registry.mjs +47 -0
- package/entity/localized_core.mjs +141 -0
- package/entity/logical_entity.mjs +25 -0
- package/entity/logical_entity_id_registry.mjs +40 -0
- package/entity/node_hash.mjs +15 -0
- package/entity/presentation_registry.mjs +44 -0
- package/entity/profile.mjs +256 -0
- package/entity/replica.mjs +20 -0
- package/entity/session_snapshot_registry.mjs +38 -0
- package/federation/chunk_fetch_pending.mjs +128 -0
- package/federation/chunk_fetch_scheduler.mjs +97 -0
- package/federation/dag_order_cache.mjs +124 -0
- package/federation/dedupe_slot.mjs +33 -0
- package/federation/message_rate_limit.mjs +23 -0
- package/federation/operator_key_chain.mjs +200 -0
- package/federation/topo_order_memo.mjs +44 -0
- package/federation/volatile_streams.mjs +52 -0
- package/federation/want_ids.mjs +158 -0
- package/files/assemble.mjs +220 -0
- package/files/assemble_stream.mjs +129 -0
- package/files/chunk_fetch.mjs +113 -0
- package/files/chunk_fetch_verify.mjs +23 -0
- package/files/chunk_provider_registry.mjs +70 -0
- package/files/chunk_responder.mjs +79 -0
- package/files/chunk_store.mjs +87 -0
- package/files/manifest.mjs +99 -0
- package/files/transfer_key.mjs +87 -0
- package/files/transfer_key_registry.mjs +108 -0
- package/governance/branch.mjs +230 -0
- package/governance/join_pow.mjs +165 -0
- package/governance/owner_succession_ballot.mjs +57 -0
- package/governance/tunables.json +7 -0
- package/index.mjs +39 -0
- package/link/channel_mux.mjs +222 -0
- package/link/frame.mjs +246 -0
- package/link/handshake.mjs +175 -0
- package/link/link.mjs +617 -0
- package/link/rtc.mjs +109 -0
- package/link/sdp_fingerprint.mjs +25 -0
- package/mailbox/consumer_registry.mjs +71 -0
- package/mailbox/deliver_or_store.mjs +190 -0
- package/mailbox/importance.mjs +72 -0
- package/mailbox/parse.mjs +73 -0
- package/mailbox/prune.mjs +106 -0
- package/mailbox/rate.mjs +86 -0
- package/mailbox/settings.mjs +53 -0
- package/mailbox/store.mjs +256 -0
- package/mailbox/tunables.json +12 -0
- package/mailbox/wire.mjs +40 -0
- package/node/denylist.mjs +309 -0
- package/node/entity_store.mjs +214 -0
- package/node/identity.mjs +99 -0
- package/node/instance.mjs +115 -0
- package/node/network.mjs +306 -0
- package/node/personal_block.mjs +286 -0
- package/node/reputation_store.mjs +326 -0
- package/node/retention_policy.mjs +82 -0
- package/node/signaling_config.mjs +32 -0
- package/node/storage.mjs +30 -0
- package/node/storage_plugins.mjs +71 -0
- package/node/user_paths.mjs +10 -0
- package/overlay/index.mjs +191 -0
- package/package.json +73 -0
- package/permissions/bitmask.mjs +35 -0
- package/permissions/evaluator.mjs +90 -0
- package/permissions/index.mjs +12 -0
- package/permissions/layered.mjs +39 -0
- package/registries/action.mjs +94 -0
- package/registries/event_type.mjs +75 -0
- package/registries/inbound.mjs +90 -0
- package/registries/p2p_viewer.mjs +35 -0
- package/registries/part_path.mjs +35 -0
- package/registries/room_provider.mjs +47 -0
- package/reputation/engine.mjs +458 -0
- package/reputation/math.mjs +73 -0
- package/reputation/pick_score.mjs +11 -0
- package/reputation/relay_dedupe.mjs +22 -0
- package/reputation/tunables.json +36 -0
- package/rooms/scoped_link.mjs +185 -0
- package/schemas/discovery.mjs +110 -0
- package/schemas/federation_pull.mjs +102 -0
- package/schemas/mailbox.mjs +37 -0
- package/schemas/remote_event.mjs +17 -0
- package/timeline/append_core.mjs +54 -0
- package/timeline/materialize_runner.mjs +26 -0
- package/timeline/prune.mjs +39 -0
- package/timeline/retention.mjs +54 -0
- package/timeline/retention_runner.mjs +35 -0
- package/timeline/verify_remote.mjs +25 -0
- package/transport/group_link_set.mjs +324 -0
- package/transport/ice_servers.mjs +72 -0
- package/transport/link_registry.mjs +749 -0
- package/transport/peer_identity_maps.mjs +94 -0
- package/transport/peer_pool.mjs +355 -0
- package/transport/remote_user_room.mjs +109 -0
- package/transport/room_scopes.mjs +2 -0
- package/transport/room_wire_action.mjs +19 -0
- package/transport/rtc_connection_budget.mjs +180 -0
- package/transport/rtc_mdns_filter.mjs +118 -0
- package/transport/stale_peer_log.mjs +50 -0
- package/transport/user_room.mjs +263 -0
- package/trust_graph/build.mjs +99 -0
- package/trust_graph/cache.mjs +44 -0
- package/trust_graph/engine.mjs +184 -0
- package/trust_graph/registry.mjs +38 -0
- package/trust_graph/resolve.mjs +105 -0
- package/trust_graph/send.mjs +88 -0
- package/trust_graph/tunables.json +13 -0
- package/utils/async_mutex.mjs +69 -0
- package/utils/debug_log.mjs +16 -0
- package/utils/duration.mjs +28 -0
- package/utils/json_io.mjs +57 -0
- package/utils/lru.mjs +22 -0
- package/utils/map_pool.mjs +27 -0
- package/wire/group_part.mjs +43 -0
- package/wire/ingress.mjs +58 -0
- package/wire/part_common.mjs +55 -0
- package/wire/part_fanout.mjs +71 -0
- package/wire/part_ingress.mjs +172 -0
- package/wire/part_invoke.mjs +81 -0
- package/wire/rate_bucket.mjs +49 -0
- package/wire/volatile_signature.mjs +76 -0
- package/wire/wait.mjs +127 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isPeerKeyBlocked, isSubjectBlocked } from '../node/denylist.mjs'
|
|
2
|
+
import { loadNetwork } from '../node/network.mjs'
|
|
3
|
+
import { loadReputation } from '../node/reputation_store.mjs'
|
|
4
|
+
import { listFederationRoomSlots } from '../registries/room_provider.mjs'
|
|
5
|
+
import { isQuarantinedPure } from '../reputation/engine.mjs'
|
|
6
|
+
import { ensureUserRoom } from '../transport/user_room.mjs'
|
|
7
|
+
|
|
8
|
+
import { getCachedTrustGraph } from './cache.mjs'
|
|
9
|
+
import { mergeGraph, pickTopFromGraph } from './engine.mjs'
|
|
10
|
+
import trustGraphTunables from './tunables.json' with { type: 'json' }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {import('./engine.mjs').TrustNode} TrustNode
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} nodeHash 64 位十六进制
|
|
18
|
+
* @returns {boolean} 是否拉黑
|
|
19
|
+
*/
|
|
20
|
+
function isNodeBlocked(nodeHash) {
|
|
21
|
+
return isPeerKeyBlocked('', nodeHash) || isSubjectBlocked({ nodeHash })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} username 副本用户名 登录名(联邦房间枚举仍按用户)
|
|
26
|
+
* @returns {Promise<Map<string, TrustNode>>} nodeHash → 节点
|
|
27
|
+
*/
|
|
28
|
+
export async function buildMergedGraph(username) {
|
|
29
|
+
return getCachedTrustGraph(username, async () => {
|
|
30
|
+
const net = loadNetwork()
|
|
31
|
+
const rep = loadReputation()
|
|
32
|
+
const blocked = new Set()
|
|
33
|
+
const quarantined = new Set()
|
|
34
|
+
for (const nodeHash of [...net.trustedPeers, ...net.explorePeers, ...net.hints.map(h => h.nodeHash)]) {
|
|
35
|
+
if (isNodeBlocked(nodeHash)) blocked.add(nodeHash)
|
|
36
|
+
if (isQuarantinedPure(rep, nodeHash)) quarantined.add(nodeHash)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} nodeHash 64 位十六进制
|
|
41
|
+
* @returns {number} 信誉分
|
|
42
|
+
*/
|
|
43
|
+
function scoreOf(nodeHash) {
|
|
44
|
+
return Number(rep.byNodeHash?.[nodeHash]?.score ?? 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const rooms = await listFederationRoomSlots(username)
|
|
48
|
+
/** @type {import('./engine.mjs').TrustGraphInputs['roomRosters']} */
|
|
49
|
+
const roomRosters = []
|
|
50
|
+
for (const room of rooms) {
|
|
51
|
+
const nodeHashes = []
|
|
52
|
+
for (const { remoteNodeHash } of room.getRoster()) {
|
|
53
|
+
if (!remoteNodeHash) continue
|
|
54
|
+
if (isNodeBlocked(remoteNodeHash)) blocked.add(remoteNodeHash)
|
|
55
|
+
else if (isQuarantinedPure(rep, remoteNodeHash)) quarantined.add(remoteNodeHash)
|
|
56
|
+
else nodeHashes.push(remoteNodeHash)
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} remoteNodeHash 64 位十六进制
|
|
60
|
+
* @returns {number} 本地主观信誉分;从未打分的新人退回 rosterDefaultScore
|
|
61
|
+
*/
|
|
62
|
+
function rosterScoreOf(remoteNodeHash) {
|
|
63
|
+
const row = rep.byNodeHash?.[remoteNodeHash]
|
|
64
|
+
return row && Number.isFinite(Number(row.score))
|
|
65
|
+
? Number(row.score)
|
|
66
|
+
: trustGraphTunables.rosterDefaultScore
|
|
67
|
+
}
|
|
68
|
+
roomRosters.push({
|
|
69
|
+
scopeId: room.groupId,
|
|
70
|
+
nodeHashes,
|
|
71
|
+
scoreOf: rosterScoreOf,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return mergeGraph({
|
|
76
|
+
trustedPeers: net.trustedPeers,
|
|
77
|
+
explorePeers: net.explorePeers,
|
|
78
|
+
hints: net.hints,
|
|
79
|
+
roomRosters,
|
|
80
|
+
blockedNodeHashes: blocked,
|
|
81
|
+
quarantinedNodeHashes: quarantined,
|
|
82
|
+
scoreOf,
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} username 副本用户名 登录名
|
|
89
|
+
* @param {number} [limit=12] 最多返回节点数
|
|
90
|
+
* @returns {Promise<TrustNode[]>} 按信誉降序
|
|
91
|
+
*/
|
|
92
|
+
export async function pickTopNodes(username, limit = trustGraphTunables.pickTopNodesDefaultLimit) {
|
|
93
|
+
await ensureUserRoom({ replicaUsername: username })
|
|
94
|
+
const rep = loadReputation()
|
|
95
|
+
const quarantined = new Set(
|
|
96
|
+
Object.keys(rep.byNodeHash || {}).filter(id => isQuarantinedPure(rep, id)),
|
|
97
|
+
)
|
|
98
|
+
return pickTopFromGraph(await buildMergedGraph(username), limit, trustGraphTunables, quarantined)
|
|
99
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** @type {Map<string, { graph: Map<string, object>, builtAt: number, revision: number }>} */
|
|
2
|
+
const cacheByUsername = new Map()
|
|
3
|
+
let revision = 0
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TTL_MS = 30_000
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @returns {number} 当前 revision
|
|
9
|
+
*/
|
|
10
|
+
export function getTrustGraphRevision() {
|
|
11
|
+
return revision
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @returns {void}
|
|
16
|
+
*/
|
|
17
|
+
export function invalidateTrustGraphCache() {
|
|
18
|
+
cacheByUsername.clear()
|
|
19
|
+
revision++
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} username 副本用户名 登录名(缓存键,与 buildMergedGraph 一致)
|
|
24
|
+
* @param {() => Promise<Map<string, object>>} build 构建函数
|
|
25
|
+
* @param {number} [ttlMs=30000] TTL
|
|
26
|
+
* @returns {Promise<Map<string, object>>} 合并后的信任图
|
|
27
|
+
*/
|
|
28
|
+
export async function getCachedTrustGraph(username, build, ttlMs = DEFAULT_TTL_MS) {
|
|
29
|
+
const key = String(username || '')
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
const cached = cacheByUsername.get(key)
|
|
32
|
+
if (cached && cached.revision === revision && now - cached.builtAt < ttlMs)
|
|
33
|
+
return cached.graph
|
|
34
|
+
|
|
35
|
+
const graph = await build()
|
|
36
|
+
cacheByUsername.set(key, { graph, builtAt: now, revision })
|
|
37
|
+
return graph
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns {void} 测试用 */
|
|
41
|
+
export function clearTrustGraphCache() {
|
|
42
|
+
cacheByUsername.clear()
|
|
43
|
+
revision = 0
|
|
44
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TrustGraph 纯图合并/选择(模拟器与 trust_graph.mjs 共用)。
|
|
3
|
+
*/
|
|
4
|
+
import { resolveFederationFanoutTopK } from './resolve.mjs'
|
|
5
|
+
import trustGraphTunables from './tunables.json' with { type: 'json' }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ nodeHash: string, score: number, scopeIds: string[] }} TrustNode
|
|
9
|
+
* @typedef {{
|
|
10
|
+
* trustedPeers?: string[],
|
|
11
|
+
* explorePeers?: string[],
|
|
12
|
+
* hints?: Array<{ nodeHash: string, source?: string, weight?: number, expiresAt?: number }>,
|
|
13
|
+
* roomRosters?: Array<{ scopeId: string, nodeHashes: string[], scoreOf?: (nodeHash: string) => number }>,
|
|
14
|
+
* blockedNodeHashes?: Set<string>,
|
|
15
|
+
* quarantinedNodeHashes?: Set<string>,
|
|
16
|
+
* now?: number,
|
|
17
|
+
* }} TrustGraphInputs
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @returns {typeof trustGraphTunables} 默认 tunables
|
|
22
|
+
*/
|
|
23
|
+
export function defaultTrustGraphTunables() {
|
|
24
|
+
return trustGraphTunables
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Map<string, TrustNode>} byNode 累积图
|
|
29
|
+
* @param {string} scopeId scope 标识
|
|
30
|
+
* @param {string} nodeHash 64 位十六进制
|
|
31
|
+
* @param {number} score 信誉分
|
|
32
|
+
*/
|
|
33
|
+
export function mergeTrustNode(byNode, scopeId, nodeHash, score) {
|
|
34
|
+
const previous = byNode.get(nodeHash)
|
|
35
|
+
if (previous) {
|
|
36
|
+
const seenCount = previous.scopeIds.length
|
|
37
|
+
previous.score = (previous.score * seenCount + score) / (seenCount + 1)
|
|
38
|
+
if (!previous.scopeIds.includes(scopeId)) previous.scopeIds.push(scopeId)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
byNode.set(nodeHash, { nodeHash, score, scopeIds: [scopeId] })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {TrustGraphInputs} inputs 图输入
|
|
46
|
+
* @param {typeof trustGraphTunables} [tunables] tunables
|
|
47
|
+
* @returns {Map<string, TrustNode>} nodeHash → 节点
|
|
48
|
+
*/
|
|
49
|
+
export function mergeGraph(inputs, tunables = trustGraphTunables) {
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {{
|
|
52
|
+
* scopeIds: Set<string>,
|
|
53
|
+
* networkScores: number[],
|
|
54
|
+
* rosterScores: number[],
|
|
55
|
+
* hintWeightSum: number,
|
|
56
|
+
* hintSources: Set<string>,
|
|
57
|
+
* }} NodeEvidence
|
|
58
|
+
*/
|
|
59
|
+
/** @type {Map<string, NodeEvidence>} */
|
|
60
|
+
const evidenceByNode = new Map()
|
|
61
|
+
const blocked = inputs.blockedNodeHashes || new Set()
|
|
62
|
+
const quarantined = inputs.quarantinedNodeHashes || new Set()
|
|
63
|
+
const damp = Number(tunables.quarantineTrustDamp ?? 0.35)
|
|
64
|
+
const now = inputs.now ?? Date.now()
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} nodeHash 64 位十六进制
|
|
67
|
+
* @returns {boolean} 是否拉黑
|
|
68
|
+
*/
|
|
69
|
+
function isBlocked(nodeHash) {
|
|
70
|
+
return blocked.has(nodeHash)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} nodeHash 64 位十六进制
|
|
75
|
+
* @returns {boolean} 是否本地隔离
|
|
76
|
+
*/
|
|
77
|
+
function isQuarantined(nodeHash) {
|
|
78
|
+
return quarantined.has(nodeHash)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} nodeHash 64 位十六进制
|
|
83
|
+
* @returns {NodeEvidence} 节点证据容器
|
|
84
|
+
*/
|
|
85
|
+
function nodeEvidence(nodeHash) {
|
|
86
|
+
let ev = evidenceByNode.get(nodeHash)
|
|
87
|
+
if (!ev) {
|
|
88
|
+
ev = {
|
|
89
|
+
scopeIds: new Set(),
|
|
90
|
+
networkScores: [],
|
|
91
|
+
rosterScores: [],
|
|
92
|
+
hintWeightSum: 0,
|
|
93
|
+
hintSources: new Set(),
|
|
94
|
+
}
|
|
95
|
+
evidenceByNode.set(nodeHash, ev)
|
|
96
|
+
}
|
|
97
|
+
return ev
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const nodeHash of [...inputs.trustedPeers || [], ...inputs.explorePeers || []]) {
|
|
101
|
+
if (isBlocked(nodeHash)) continue
|
|
102
|
+
const score = inputs.scoreOf?.(nodeHash) ?? 0
|
|
103
|
+
const ev = nodeEvidence(nodeHash)
|
|
104
|
+
ev.scopeIds.add('network')
|
|
105
|
+
ev.networkScores.push(Number(score))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const hint of inputs.hints || []) {
|
|
109
|
+
if (hint.expiresAt && hint.expiresAt <= now) continue
|
|
110
|
+
if (isBlocked(hint.nodeHash)) continue
|
|
111
|
+
const ev = nodeEvidence(hint.nodeHash)
|
|
112
|
+
ev.scopeIds.add(`hint:${hint.source ?? 'unknown'}`)
|
|
113
|
+
const rawWeight = Number(hint.weight ?? tunables.hintDefaultWeight)
|
|
114
|
+
if (Number.isFinite(rawWeight) && rawWeight > 0)
|
|
115
|
+
ev.hintWeightSum += rawWeight
|
|
116
|
+
ev.hintSources.add(String(hint.source ?? 'unknown'))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const room of inputs.roomRosters || [])
|
|
120
|
+
for (const remoteNodeHash of room.nodeHashes) {
|
|
121
|
+
if (!remoteNodeHash || isBlocked(remoteNodeHash)) continue
|
|
122
|
+
// 名册只能告诉你「这个节点存在于该 scope」;信任分一律取本地主观信誉,
|
|
123
|
+
// 仅当本地从未给它打过分(新人)时才退回 rosterDefaultScore。
|
|
124
|
+
const local = room.scoreOf?.(remoteNodeHash) ?? inputs.scoreOf?.(remoteNodeHash)
|
|
125
|
+
const score = Number.isFinite(Number(local)) ? Number(local) : tunables.rosterDefaultScore
|
|
126
|
+
const ev = nodeEvidence(remoteNodeHash)
|
|
127
|
+
ev.scopeIds.add(room.scopeId)
|
|
128
|
+
ev.rosterScores.push(score)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @type {Map<string, TrustNode>} */
|
|
132
|
+
const byNode = new Map()
|
|
133
|
+
for (const [nodeHash, ev] of evidenceByNode.entries()) {
|
|
134
|
+
const networkMean = ev.networkScores.length
|
|
135
|
+
? ev.networkScores.reduce((s, n) => s + n, 0) / ev.networkScores.length
|
|
136
|
+
: NaN
|
|
137
|
+
const rosterMean = ev.rosterScores.length
|
|
138
|
+
? ev.rosterScores.reduce((s, n) => s + n, 0) / ev.rosterScores.length
|
|
139
|
+
: NaN
|
|
140
|
+
const baseScores = [networkMean, rosterMean].filter(Number.isFinite)
|
|
141
|
+
const baseScore = baseScores.length
|
|
142
|
+
? baseScores.reduce((s, n) => s + n, 0) / baseScores.length
|
|
143
|
+
: 0
|
|
144
|
+
|
|
145
|
+
// Hint 只做“发现增益”:收益按总权重指数饱和,防止多源投毒线性抬分。
|
|
146
|
+
const hintScale = 0.1
|
|
147
|
+
const saturatedHintLift = tunables.hintMaxBonus * (1 - Math.exp(-ev.hintWeightSum / hintScale))
|
|
148
|
+
const hasHardEvidence = ev.networkScores.length + ev.rosterScores.length > 0
|
|
149
|
+
const hintReliability = hasHardEvidence ? 1 : 0.35
|
|
150
|
+
let score = baseScore + saturatedHintLift * hintReliability
|
|
151
|
+
if (isQuarantined(nodeHash))
|
|
152
|
+
score *= damp
|
|
153
|
+
|
|
154
|
+
byNode.set(nodeHash, { nodeHash, score, scopeIds: [...ev.scopeIds] })
|
|
155
|
+
}
|
|
156
|
+
return byNode
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {Map<string, TrustNode>} graph 合并图
|
|
161
|
+
* @param {number} [limit] 最多返回节点数
|
|
162
|
+
* @param {typeof trustGraphTunables} [tunables] tunables
|
|
163
|
+
* @param {Set<string>} [quarantinedNodeHashes] 本地隔离节点(踢出 topK)
|
|
164
|
+
* @returns {TrustNode[]} 按信誉降序
|
|
165
|
+
*/
|
|
166
|
+
export function pickTopFromGraph(graph, limit = trustGraphTunables.pickTopNodesDefaultLimit, tunables = trustGraphTunables, quarantinedNodeHashes = new Set()) {
|
|
167
|
+
const k = limit ?? tunables.pickTopNodesDefaultLimit
|
|
168
|
+
return [...graph.values()]
|
|
169
|
+
.filter(node => !quarantinedNodeHashes.has(node.nodeHash))
|
|
170
|
+
.sort((a, b) => b.score - a.score)
|
|
171
|
+
.slice(0, Math.max(1, k))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {TrustGraphInputs} inputs 图输入
|
|
176
|
+
* @param {number} [limit] fanout K
|
|
177
|
+
* @param {typeof trustGraphTunables} [tunables] tunables
|
|
178
|
+
* @returns {TrustNode[]} Top-K 节点
|
|
179
|
+
*/
|
|
180
|
+
export function pickTop(inputs, limit, tunables = trustGraphTunables) {
|
|
181
|
+
const graph = mergeGraph(inputs, tunables)
|
|
182
|
+
const k = limit ?? resolveFederationFanoutTopK(graph.size, tunables)
|
|
183
|
+
return pickTopFromGraph(graph, k, tunables, inputs.quarantinedNodeHashes || new Set())
|
|
184
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { buildMergedGraph, pickTopNodes } from './build.mjs'
|
|
2
|
+
import { fanoutToTopNodes, sendToNode } from './send.mjs'
|
|
3
|
+
|
|
4
|
+
/** @type {Map<string, import('./registry.mjs').TrustGraphProvider>} */
|
|
5
|
+
const providersByOwner = new Map()
|
|
6
|
+
|
|
7
|
+
/** 用户级 P2P trust graph(registerTrustGraphProvider 注册) */
|
|
8
|
+
export const DEFAULT_TRUST_GRAPH_OWNER = 'default'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} ownerId 注册方(如 chat)
|
|
12
|
+
* @param {import('./registry.mjs').TrustGraphProvider} impl 信任图实现
|
|
13
|
+
* @returns {void}
|
|
14
|
+
*/
|
|
15
|
+
export function registerTrustGraphProvider(ownerId, impl) {
|
|
16
|
+
providersByOwner.set(String(ownerId), impl)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @returns {void} */
|
|
20
|
+
export function clearTrustGraphProvider() {
|
|
21
|
+
providersByOwner.clear()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} [ownerId=DEFAULT_TRUST_GRAPH_OWNER] 注册方 ID
|
|
26
|
+
* @returns {import('./registry.mjs').TrustGraphProvider} 已注册实现
|
|
27
|
+
*/
|
|
28
|
+
export function requireTrustGraphProvider(ownerId = DEFAULT_TRUST_GRAPH_OWNER) {
|
|
29
|
+
const impl = providersByOwner.get(String(ownerId))
|
|
30
|
+
if (!impl)
|
|
31
|
+
throw new Error(`p2p: registerTrustGraphProvider('${ownerId}') must run before trust graph fanout`)
|
|
32
|
+
return impl
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @returns {import('./registry.mjs').TrustGraphProvider} 默认信任图实现 */
|
|
36
|
+
export function createDefaultTrustGraphProvider() {
|
|
37
|
+
return { buildMergedGraph, pickTopNodes, sendToNode, fanoutToTopNodes }
|
|
38
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享 tunables 缩放:ratio + floor + cap,仿真与运行时共用。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @typedef {{ floor: number, ratio: number, cap?: number }} ScaleSpec */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {number} n 分母(在场人数 / 候选 peer 数等)
|
|
9
|
+
* @param {ScaleSpec} spec floor、ratio、可选 cap
|
|
10
|
+
* @returns {number} 有效整数阈值 ≥ floor
|
|
11
|
+
*/
|
|
12
|
+
export function scaleCount(n, { floor, ratio, cap = Infinity }) {
|
|
13
|
+
const safeN = Math.max(0, Math.floor(Number(n) || 0))
|
|
14
|
+
const scaled = Math.ceil(safeN * Number(ratio) || 0)
|
|
15
|
+
const capped = Number.isFinite(cap) ? Math.min(scaled, Math.floor(cap)) : scaled
|
|
16
|
+
const bounded = safeN > 0 ? Math.min(capped, safeN) : Math.max(1, Math.floor(floor))
|
|
17
|
+
return Math.max(Math.max(1, Math.floor(floor)), bounded)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {number} n 群 activeMembers 或联邦应答 peer 数
|
|
22
|
+
* @param {object} tunables 归档 tunables
|
|
23
|
+
* @returns {number} 收集阶段 quorum peerMin
|
|
24
|
+
*/
|
|
25
|
+
export function resolveArchiveQuorumPeerMin(n, tunables) {
|
|
26
|
+
return scaleCount(n, {
|
|
27
|
+
floor: tunables.archiveQuorumPeerMinFloor ?? tunables.archiveQuorumPeerMin ?? 2,
|
|
28
|
+
ratio: tunables.archiveQuorumPeerMinRatio ?? 0.25,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {number} n 群 activeMembers 或联邦应答 peer 数
|
|
34
|
+
* @param {object} tunables 归档 tunables
|
|
35
|
+
* @returns {number} 无正信誉时 strictMin(硬钳 ≥2)
|
|
36
|
+
*/
|
|
37
|
+
export function resolveArchiveQuorumPeerStrictMin(n, tunables) {
|
|
38
|
+
const raw = scaleCount(n, {
|
|
39
|
+
floor: tunables.archiveQuorumPeerStrictMinFloor ?? tunables.archiveQuorumPeerStrictMin ?? 2,
|
|
40
|
+
ratio: tunables.archiveQuorumPeerStrictMinRatio ?? 0.5,
|
|
41
|
+
})
|
|
42
|
+
return Math.max(2, raw)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {number} peerCount 已知在线 relay 候选数
|
|
47
|
+
* @param {object} tunables 邮箱 tunables
|
|
48
|
+
* @returns {number} 受信层 relay fanout
|
|
49
|
+
*/
|
|
50
|
+
export function resolveMailboxRelayFanout(peerCount, tunables) {
|
|
51
|
+
if (Number.isFinite(tunables.relayFanoutTrusted))
|
|
52
|
+
return Math.max(1, Math.min(peerCount > 0 ? peerCount : Infinity, Math.floor(tunables.relayFanoutTrusted)))
|
|
53
|
+
return scaleCount(peerCount, {
|
|
54
|
+
floor: tunables.relayFanoutTrustedFloor ?? 3,
|
|
55
|
+
ratio: tunables.relayFanoutTrustedRatio ?? 0.3,
|
|
56
|
+
cap: tunables.relayFanoutTrustedCap ?? 32,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {number} peerCount 已知在线 relay 候选数
|
|
62
|
+
* @param {object} tunables 邮箱 tunables
|
|
63
|
+
* @returns {number} want 广播 fanout
|
|
64
|
+
*/
|
|
65
|
+
export function resolveMailboxWantFanout(peerCount, tunables) {
|
|
66
|
+
if (Number.isFinite(tunables.wantFanout))
|
|
67
|
+
return Math.max(1, Math.min(peerCount > 0 ? peerCount : Infinity, Math.floor(tunables.wantFanout)))
|
|
68
|
+
return scaleCount(peerCount, {
|
|
69
|
+
floor: tunables.wantFanoutFloor ?? 3,
|
|
70
|
+
ratio: tunables.wantFanoutRatio ?? 0.4,
|
|
71
|
+
cap: tunables.wantFanoutCap ?? 32,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {number} rosterSize 信任图 roster 大小
|
|
77
|
+
* @param {object} tunables 信任图 tunables
|
|
78
|
+
* @returns {number} 联邦 fanout Top-K
|
|
79
|
+
*/
|
|
80
|
+
export function resolveFederationFanoutTopK(rosterSize, tunables) {
|
|
81
|
+
if (Number.isFinite(tunables.federationFanoutTopK))
|
|
82
|
+
return Math.max(1, Math.min(rosterSize > 0 ? rosterSize : Infinity, Math.floor(tunables.federationFanoutTopK)))
|
|
83
|
+
return scaleCount(rosterSize, {
|
|
84
|
+
floor: tunables.federationFanoutTopKFloor ?? 3,
|
|
85
|
+
ratio: tunables.federationFanoutTopKRatio ?? 0.35,
|
|
86
|
+
cap: tunables.federationFanoutTopKCap ?? 16,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {object} tunables 归档 tunables
|
|
92
|
+
* @param {number} [activeMemberCount] 群 active 成员数
|
|
93
|
+
* @param {number} [candidatePeerCount] 联邦候选 peer 数
|
|
94
|
+
* @returns {{ peerMin: number, strictMin: number }} 归档 quorum 阈值
|
|
95
|
+
*/
|
|
96
|
+
export function resolveArchiveQuorumThresholds(tunables, activeMemberCount, candidatePeerCount) {
|
|
97
|
+
const n = Math.max(
|
|
98
|
+
Number(activeMemberCount) || 0,
|
|
99
|
+
Number(candidatePeerCount) || 0,
|
|
100
|
+
)
|
|
101
|
+
return {
|
|
102
|
+
peerMin: resolveArchiveQuorumPeerMin(n, tunables),
|
|
103
|
+
strictMin: resolveArchiveQuorumPeerStrictMin(n, tunables),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
2
|
+
import { getNodeHash } from '../node/identity.mjs'
|
|
3
|
+
import { loadReputation } from '../node/reputation_store.mjs'
|
|
4
|
+
import { listFederationRoomSlots } from '../registries/room_provider.mjs'
|
|
5
|
+
import { isQuarantinedPure } from '../reputation/engine.mjs'
|
|
6
|
+
import { sendToNodeLink } from '../transport/link_registry.mjs'
|
|
7
|
+
import { USER_ROOM_SCOPE } from '../transport/room_scopes.mjs'
|
|
8
|
+
import { ensureUserRoom } from '../transport/user_room.mjs'
|
|
9
|
+
|
|
10
|
+
import { buildMergedGraph } from './build.mjs'
|
|
11
|
+
import { pickTopFromGraph } from './engine.mjs'
|
|
12
|
+
import { resolveFederationFanoutTopK } from './resolve.mjs'
|
|
13
|
+
import trustGraphTunables from './tunables.json' with { type: 'json' }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} username 副本用户名 登录名
|
|
17
|
+
* @param {string} targetNodeHash 64 位十六进制
|
|
18
|
+
* @param {string} actionName Trystero 动作
|
|
19
|
+
* @param {unknown} payload 载荷
|
|
20
|
+
* @param {Map<string, object>} [graph] 已构建信任图(省略时内部构建)
|
|
21
|
+
* @returns {Promise<boolean>} 是否已发送
|
|
22
|
+
*/
|
|
23
|
+
export async function sendToNode(username, targetNodeHash, actionName, payload, graph) {
|
|
24
|
+
const target = normalizeHex64(targetNodeHash) || String(targetNodeHash || '').trim().toLowerCase()
|
|
25
|
+
if (!isHex64(target)) return false
|
|
26
|
+
await ensureUserRoom({ replicaUsername: username })
|
|
27
|
+
|
|
28
|
+
// 已直连 peer 不经 trust-graph scope 也应能收发 node scope action(非成员 CAS chunk / follow hint 等)
|
|
29
|
+
if (await sendToNodeLink(target, { scope: 'node', action: String(actionName), payload }))
|
|
30
|
+
return true
|
|
31
|
+
|
|
32
|
+
const merged = graph ?? await buildMergedGraph(username)
|
|
33
|
+
const targetNode = merged.get(target)
|
|
34
|
+
if (!targetNode?.scopeIds.length) return false
|
|
35
|
+
const selfNodeHash = getNodeHash()
|
|
36
|
+
|
|
37
|
+
const rooms = await listFederationRoomSlots(username)
|
|
38
|
+
const userRooms = rooms.filter(room => room.groupId === USER_ROOM_SCOPE)
|
|
39
|
+
const groupRooms = rooms.filter(room => room.groupId !== USER_ROOM_SCOPE)
|
|
40
|
+
|
|
41
|
+
for (const userRoom of userRooms) {
|
|
42
|
+
const peerId = userRoom.getPeerIdByNodeHash(target)
|
|
43
|
+
if (peerId) {
|
|
44
|
+
userRoom.sendToPeer(peerId, actionName, payload)
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const room of groupRooms) {
|
|
50
|
+
if (!targetNode.scopeIds.includes(room.groupId)) continue
|
|
51
|
+
const peerId = room.getPeerIdByNodeHash(targetNodeHash)
|
|
52
|
+
if (peerId) {
|
|
53
|
+
room.sendToPeer(peerId, actionName, payload)
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
if (room.pickFallbackPeerIds) {
|
|
57
|
+
const targets = await room.pickFallbackPeerIds(selfNodeHash)
|
|
58
|
+
if (targets.length) {
|
|
59
|
+
for (const targetPeerId of targets.slice(0, trustGraphTunables.sendFallbackPeerLimit))
|
|
60
|
+
room.sendToPeer(targetPeerId, actionName, payload)
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} username 副本用户名 登录名
|
|
70
|
+
* @param {string} actionName action 名
|
|
71
|
+
* @param {unknown} payload 载荷
|
|
72
|
+
* @param {number} [limit] K(省略时按 roster 规模缩放)
|
|
73
|
+
* @returns {Promise<number>} 发送次数
|
|
74
|
+
*/
|
|
75
|
+
export async function fanoutToTopNodes(username, actionName, payload, limit) {
|
|
76
|
+
await ensureUserRoom({ replicaUsername: username })
|
|
77
|
+
const graph = await buildMergedGraph(username)
|
|
78
|
+
const k = limit ?? resolveFederationFanoutTopK(graph.size, trustGraphTunables)
|
|
79
|
+
const rep = loadReputation()
|
|
80
|
+
const quarantined = new Set(
|
|
81
|
+
Object.keys(rep.byNodeHash || {}).filter(id => isQuarantinedPure(rep, id)),
|
|
82
|
+
)
|
|
83
|
+
const topNodes = pickTopFromGraph(graph, k, trustGraphTunables, quarantined)
|
|
84
|
+
let sent = 0
|
|
85
|
+
for (const { nodeHash } of topNodes)
|
|
86
|
+
if (await sendToNode(username, nodeHash, actionName, payload, graph)) sent++
|
|
87
|
+
return sent
|
|
88
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"federationFanoutTopKFloor": 3,
|
|
3
|
+
"federationFanoutTopKRatio": 0.35,
|
|
4
|
+
"federationFanoutTopKCap": 16,
|
|
5
|
+
"federationChunkFetchFanoutK": 6,
|
|
6
|
+
"federationChunkMaxBytes": 524288,
|
|
7
|
+
"hintDefaultWeight": 0.1,
|
|
8
|
+
"hintMaxBonus": 0.15,
|
|
9
|
+
"rosterDefaultScore": 0.1,
|
|
10
|
+
"sendFallbackPeerLimit": 4,
|
|
11
|
+
"pickTopNodesDefaultLimit": 12,
|
|
12
|
+
"quarantineTrustDamp": 0.35
|
|
13
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程内 per-key 异步互斥锁(队列式,避免 Promise 链堆积)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @type {Map<string, { locked: boolean, queue: Array<() => void> }>} */
|
|
6
|
+
const mutexes = new Map()
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} lockKey 锁键
|
|
10
|
+
* @returns {{ locked: boolean, queue: Array<() => void> }} 互斥状态
|
|
11
|
+
*/
|
|
12
|
+
function mutexState(lockKey) {
|
|
13
|
+
let state = mutexes.get(lockKey)
|
|
14
|
+
if (!state) {
|
|
15
|
+
state = { locked: false, queue: [] }
|
|
16
|
+
mutexes.set(lockKey, state)
|
|
17
|
+
}
|
|
18
|
+
return state
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} lockKey 锁键
|
|
23
|
+
* @param {{ locked: boolean, queue: Array<() => void> }} state 互斥状态
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*/
|
|
26
|
+
function releaseMutex(lockKey, state) {
|
|
27
|
+
const next = state.queue.shift()
|
|
28
|
+
if (next) setTimeout(next, 0)
|
|
29
|
+
else {
|
|
30
|
+
state.locked = false
|
|
31
|
+
mutexes.delete(lockKey)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} key 锁键
|
|
37
|
+
* @param {() => Promise<T>} fn 临界区
|
|
38
|
+
* @returns {Promise<T>} `fn` 的解析结果
|
|
39
|
+
* @template T
|
|
40
|
+
*/
|
|
41
|
+
export async function withAsyncMutex(key, fn) {
|
|
42
|
+
const lockKey = String(key)
|
|
43
|
+
const state = mutexState(lockKey)
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
/**
|
|
46
|
+
* @returns {void}
|
|
47
|
+
*/
|
|
48
|
+
const run = () => {
|
|
49
|
+
Promise.resolve()
|
|
50
|
+
.then(fn)
|
|
51
|
+
.then(resolve, reject)
|
|
52
|
+
.finally(() => releaseMutex(lockKey, state))
|
|
53
|
+
}
|
|
54
|
+
if (state.locked) state.queue.push(run)
|
|
55
|
+
else {
|
|
56
|
+
state.locked = true
|
|
57
|
+
run()
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} keyPrefix 前缀
|
|
64
|
+
* @returns {(key: string, fn: () => Promise<T>) => Promise<T>} 带前缀的 mutex 函数
|
|
65
|
+
* @template T
|
|
66
|
+
*/
|
|
67
|
+
export function asyncMutexForPrefix(keyPrefix) {
|
|
68
|
+
return (key, fn) => withAsyncMutex(`${keyPrefix}:${key}`, fn)
|
|
69
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const dir = path.resolve(process.cwd(), 'debug_logs')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* P2P 观测落盘:写入进程 cwd 下 `debug_logs/`(与 monorepo `debugLog` 行为对齐)。
|
|
8
|
+
* @param {string} name 日志文件名(不含扩展名)
|
|
9
|
+
* @param {unknown} data 调试数据
|
|
10
|
+
* @returns {Promise<void>}
|
|
11
|
+
*/
|
|
12
|
+
export async function debugLog(name, data) {
|
|
13
|
+
const text = Object(data) instanceof String ? data : JSON.stringify(data)
|
|
14
|
+
await fs.promises.mkdir(dir, { recursive: true })
|
|
15
|
+
await fs.promises.appendFile(path.join(dir, `${name}.log`), text)
|
|
16
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 将持续时间字符串转换为毫秒。
|
|
3
|
+
* @param {string|number} duration - 持续时间字符串 (例如, "1d", "2h", "30m", "10s") 或毫秒数。
|
|
4
|
+
* @returns {number} 持续时间(以毫秒为单位)。
|
|
5
|
+
*/
|
|
6
|
+
export function ms(duration) {
|
|
7
|
+
if (Object(duration) instanceof Number) return duration
|
|
8
|
+
|
|
9
|
+
const match = /(\d+)\s*(\w+)/.exec(duration)
|
|
10
|
+
if (!match)
|
|
11
|
+
throw new Error('Invalid duration format')
|
|
12
|
+
|
|
13
|
+
const value = Number(match[1])
|
|
14
|
+
const unit = match[2]
|
|
15
|
+
|
|
16
|
+
switch (unit) {
|
|
17
|
+
case 's':
|
|
18
|
+
return value * 1000
|
|
19
|
+
case 'm':
|
|
20
|
+
return value * 60 * 1000
|
|
21
|
+
case 'h':
|
|
22
|
+
return value * 60 * 60 * 1000
|
|
23
|
+
case 'd':
|
|
24
|
+
return value * 24 * 60 * 60 * 1000
|
|
25
|
+
default:
|
|
26
|
+
throw new Error('Invalid duration unit')
|
|
27
|
+
}
|
|
28
|
+
}
|