@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,134 @@
|
|
|
1
|
+
/** @typedef {{ id: string, priority: number, caps: { canDiscover?: boolean, canSignal?: boolean, canRelay?: boolean }, advertise?: (topic: string, bytes: Uint8Array) => (() => void) | Promise<() => void>, subscribe?: (topic: string, onAdvert: (bytes: Uint8Array, meta?: object) => void) => (() => void) | Promise<() => void>, sendSignal?: (topic: string, to: string, bytes: Uint8Array) => void | Promise<void>, onSignal?: (topic: string, onSignal: (bytes: Uint8Array, meta?: object) => void) => (() => void) | Promise<() => void> }} DiscoveryProvider */
|
|
2
|
+
|
|
3
|
+
/** @type {Map<string, DiscoveryProvider>} */
|
|
4
|
+
const providers = new Map()
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 注册 discovery provider。
|
|
8
|
+
* @param {DiscoveryProvider} provider 发现提供者
|
|
9
|
+
* @returns {() => void} 注销函数
|
|
10
|
+
*/
|
|
11
|
+
export function registerDiscoveryProvider(provider) {
|
|
12
|
+
if (!provider?.id) throw new Error('p2p: discovery provider requires id')
|
|
13
|
+
providers.set(String(provider.id), provider)
|
|
14
|
+
return () => unregisterDiscoveryProvider(provider.id)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 注销 discovery provider。
|
|
19
|
+
* @param {string} id 提供者 id
|
|
20
|
+
* @returns {void}
|
|
21
|
+
*/
|
|
22
|
+
export function unregisterDiscoveryProvider(id) {
|
|
23
|
+
providers.delete(String(id))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 列出已注册的 discovery provider(按 priority 排序)。
|
|
28
|
+
* @returns {DiscoveryProvider[]} 提供者列表
|
|
29
|
+
*/
|
|
30
|
+
export function listDiscoveryProviders() {
|
|
31
|
+
return [...providers.values()].sort((left, right) => Number(left.priority || 0) - Number(right.priority || 0))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 通过所有可用 provider 广播 topic advert。
|
|
36
|
+
* @param {string} topic advert 主题
|
|
37
|
+
* @param {Uint8Array} bytes advert 载荷
|
|
38
|
+
* @returns {Promise<() => void>} 统一取消函数
|
|
39
|
+
*/
|
|
40
|
+
export async function advertiseTopic(topic, bytes) {
|
|
41
|
+
const cleanups = []
|
|
42
|
+
for (const provider of listDiscoveryProviders()) {
|
|
43
|
+
if (!provider.caps?.canDiscover || typeof provider.advertise !== 'function') continue
|
|
44
|
+
let cleanup = null
|
|
45
|
+
try {
|
|
46
|
+
cleanup = await provider.advertise(topic, bytes)
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.warn(`p2p: discovery advertise failed for ${provider.id}`, error)
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
53
|
+
}
|
|
54
|
+
return () => {
|
|
55
|
+
for (const cleanup of cleanups)
|
|
56
|
+
try { cleanup() } catch { /* ignore */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 通过所有可用 provider 订阅 topic advert。
|
|
62
|
+
* @param {string} topic advert 主题
|
|
63
|
+
* @param {(bytes: Uint8Array, meta?: object) => void} onAdvert advert 回调
|
|
64
|
+
* @returns {Promise<() => void>} 统一取消函数
|
|
65
|
+
*/
|
|
66
|
+
export async function subscribeTopic(topic, onAdvert) {
|
|
67
|
+
const cleanups = []
|
|
68
|
+
for (const provider of listDiscoveryProviders()) {
|
|
69
|
+
if (!provider.caps?.canDiscover || typeof provider.subscribe !== 'function') continue
|
|
70
|
+
let cleanup = null
|
|
71
|
+
try {
|
|
72
|
+
cleanup = await provider.subscribe(topic, onAdvert)
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
console.warn(`p2p: discovery subscribe failed for ${provider.id}`, error)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
79
|
+
}
|
|
80
|
+
return () => {
|
|
81
|
+
for (const cleanup of cleanups)
|
|
82
|
+
try { cleanup() } catch { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 通过 discovery provider 发送信令。
|
|
88
|
+
* @param {string} topic 信令 topic
|
|
89
|
+
* @param {string} to 目标节点标识
|
|
90
|
+
* @param {Uint8Array} bytes 信令载荷
|
|
91
|
+
* @returns {Promise<void>}
|
|
92
|
+
*/
|
|
93
|
+
export async function sendSignal(topic, to, bytes) {
|
|
94
|
+
const capable = listDiscoveryProviders().filter(provider => provider.caps?.canSignal && typeof provider.sendSignal === 'function')
|
|
95
|
+
if (!capable.length) throw new Error('p2p: no discovery provider can signal')
|
|
96
|
+
let sent = false
|
|
97
|
+
let lastError = null
|
|
98
|
+
for (const provider of capable)
|
|
99
|
+
try {
|
|
100
|
+
await Promise.resolve(provider.sendSignal(topic, to, bytes))
|
|
101
|
+
sent = true
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
lastError = error
|
|
105
|
+
console.warn(`p2p: discovery signal failed for ${provider.id}`, error)
|
|
106
|
+
}
|
|
107
|
+
if (!sent) throw lastError || new Error('p2p: no discovery provider delivered signal')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 通过所有可用 provider 监听信令。
|
|
112
|
+
* @param {string} topic 信令 topic
|
|
113
|
+
* @param {(bytes: Uint8Array, meta?: object) => void} onSignal 信令回调
|
|
114
|
+
* @returns {Promise<() => void>} 统一取消函数
|
|
115
|
+
*/
|
|
116
|
+
export async function listenSignals(topic, onSignal) {
|
|
117
|
+
const cleanups = []
|
|
118
|
+
for (const provider of listDiscoveryProviders()) {
|
|
119
|
+
if (!provider.caps?.canSignal || typeof provider.onSignal !== 'function') continue
|
|
120
|
+
let cleanup = null
|
|
121
|
+
try {
|
|
122
|
+
cleanup = await provider.onSignal(topic, onSignal)
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.warn(`p2p: discovery signal listener failed for ${provider.id}`, error)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
129
|
+
}
|
|
130
|
+
return () => {
|
|
131
|
+
for (const cleanup of cleanups)
|
|
132
|
+
try { cleanup() } catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import dgram from 'node:dgram'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PORT = 53531
|
|
5
|
+
const DEFAULT_GROUP = '239.255.42.99'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 轻量 multicast 发现插件:不做完整 DNS-SD,只复用 mDNS 的 LAN multicast 发现思路。
|
|
9
|
+
*
|
|
10
|
+
* @param {{ port?: number, group?: string }} [opts] 组播端口与组地址
|
|
11
|
+
* @returns {import('./index.mjs').DiscoveryProvider} mDNS 发现提供者
|
|
12
|
+
*/
|
|
13
|
+
export function createMdnsDiscoveryProvider(opts = {}) {
|
|
14
|
+
const port = Number(opts.port) || DEFAULT_PORT
|
|
15
|
+
const group = String(opts.group || DEFAULT_GROUP)
|
|
16
|
+
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
17
|
+
let bound = false
|
|
18
|
+
let bindPromise = null
|
|
19
|
+
/** @type {Map<string, Set<Function>>} */
|
|
20
|
+
const advertListeners = new Map()
|
|
21
|
+
/** @type {Map<string, Set<Function>>} */
|
|
22
|
+
const signalListeners = new Map()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 绑定 UDP socket 并注册组播消息处理器。
|
|
26
|
+
* @returns {Promise<void>}
|
|
27
|
+
*/
|
|
28
|
+
async function ensureBound() {
|
|
29
|
+
if (bound) return
|
|
30
|
+
if (!bindPromise)
|
|
31
|
+
bindPromise = (async () => {
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
socket.once('error', reject)
|
|
34
|
+
socket.bind(port, '0.0.0.0', () => {
|
|
35
|
+
socket.off('error', reject)
|
|
36
|
+
socket.addMembership(group)
|
|
37
|
+
socket.setMulticastTTL(1)
|
|
38
|
+
resolve()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
socket.on('message', raw => {
|
|
42
|
+
let packet
|
|
43
|
+
try { packet = JSON.parse(String(raw)) } catch { return }
|
|
44
|
+
const listeners = packet.type === 'advert'
|
|
45
|
+
? advertListeners.get(String(packet.topic || ''))
|
|
46
|
+
: signalListeners.get(String(packet.topic || ''))
|
|
47
|
+
if (!listeners?.size) return
|
|
48
|
+
const bytes = Uint8Array.from(Buffer.from(String(packet.data || ''), 'base64'))
|
|
49
|
+
for (const listener of listeners)
|
|
50
|
+
listener(bytes, { provider: 'mdns' })
|
|
51
|
+
})
|
|
52
|
+
bound = true
|
|
53
|
+
})()
|
|
54
|
+
.finally(() => {
|
|
55
|
+
if (!bound) bindPromise = null
|
|
56
|
+
})
|
|
57
|
+
await bindPromise
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 向组播组发送 advert 或 signal 包。
|
|
62
|
+
* @param {'advert' | 'signal'} type 包类型
|
|
63
|
+
* @param {string} topic topic 名称
|
|
64
|
+
* @param {Uint8Array} bytes 载荷字节
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
async function multicast(type, topic, bytes) {
|
|
68
|
+
await ensureBound()
|
|
69
|
+
const packet = Buffer.from(JSON.stringify({
|
|
70
|
+
type,
|
|
71
|
+
topic,
|
|
72
|
+
data: Buffer.from(bytes).toString('base64'),
|
|
73
|
+
}))
|
|
74
|
+
await new Promise((resolve, reject) => {
|
|
75
|
+
socket.send(packet, port, group, error => error ? reject(error) : resolve())
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 向 topic bucket 注册监听器。
|
|
81
|
+
* @param {Map<string, Set<Function>>} bucket topic → 监听器集合
|
|
82
|
+
* @param {string} topic 订阅 topic
|
|
83
|
+
* @param {Function} listener 回调函数
|
|
84
|
+
* @returns {() => void} 取消订阅函数
|
|
85
|
+
*/
|
|
86
|
+
function addListener(bucket, topic, listener) {
|
|
87
|
+
if (!bucket.has(topic)) bucket.set(topic, new Set())
|
|
88
|
+
bucket.get(topic).add(listener)
|
|
89
|
+
return () => bucket.get(topic)?.delete(listener)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
id: 'mdns',
|
|
94
|
+
priority: 10,
|
|
95
|
+
caps: { canDiscover: true, canSignal: true, canRelay: false },
|
|
96
|
+
/**
|
|
97
|
+
* 周期性组播广播 advert。
|
|
98
|
+
* @param {string} topic advert 主题
|
|
99
|
+
* @param {Uint8Array} bytes advert 载荷
|
|
100
|
+
* @returns {Promise<() => void>} 取消广播函数
|
|
101
|
+
*/
|
|
102
|
+
async advertise(topic, bytes) {
|
|
103
|
+
await multicast('advert', topic, bytes)
|
|
104
|
+
const timer = setInterval(() => { void multicast('advert', topic, bytes).catch(() => { }) }, 30_000)
|
|
105
|
+
return () => clearInterval(timer)
|
|
106
|
+
},
|
|
107
|
+
/**
|
|
108
|
+
* 订阅组播 advert。
|
|
109
|
+
* @param {string} topic advert 主题
|
|
110
|
+
* @param {Function} onAdvert advert 回调
|
|
111
|
+
* @returns {Promise<() => void>} 取消订阅函数
|
|
112
|
+
*/
|
|
113
|
+
async subscribe(topic, onAdvert) {
|
|
114
|
+
await ensureBound()
|
|
115
|
+
return addListener(advertListeners, topic, onAdvert)
|
|
116
|
+
},
|
|
117
|
+
/**
|
|
118
|
+
* 组播发送信令(忽略单播目标)。
|
|
119
|
+
* @param {string} topic 信令 topic
|
|
120
|
+
* @param {string} _to 目标标识(未使用)
|
|
121
|
+
* @param {Uint8Array} bytes 信令载荷
|
|
122
|
+
* @returns {Promise<void>}
|
|
123
|
+
*/
|
|
124
|
+
async sendSignal(topic, _to, bytes) {
|
|
125
|
+
await multicast('signal', topic, bytes)
|
|
126
|
+
},
|
|
127
|
+
/**
|
|
128
|
+
* 订阅组播信令。
|
|
129
|
+
* @param {string} topic 信令 topic
|
|
130
|
+
* @param {Function} onSignal 信令回调
|
|
131
|
+
* @returns {Promise<() => void>} 取消订阅函数
|
|
132
|
+
*/
|
|
133
|
+
async onSignal(topic, onSignal) {
|
|
134
|
+
await ensureBound()
|
|
135
|
+
return addListener(signalListeners, topic, onSignal)
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { schnorr } from '@noble/curves/secp256k1.js'
|
|
4
|
+
|
|
5
|
+
import { sha256Hex } from '../crypto/crypto.mjs'
|
|
6
|
+
|
|
7
|
+
/** 默认 Nostr 中继 URL 列表。 */
|
|
8
|
+
export const DEFAULT_RELAY_URLS = [
|
|
9
|
+
'wss://relay.damus.io',
|
|
10
|
+
'wss://nos.lol',
|
|
11
|
+
'wss://relay.nostr.band',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
/** Nostr advert 事件 kind。 */
|
|
15
|
+
export const NOSTR_ADVERT_KIND = 27235
|
|
16
|
+
/** Nostr signal 事件 kind。 */
|
|
17
|
+
export const NOSTR_SIGNAL_KIND = 27236
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 合并默认与用户配置的中继 URL(去重)。
|
|
21
|
+
* @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
|
|
22
|
+
* @returns {string[]} 合并后的中继 URL 列表
|
|
23
|
+
*/
|
|
24
|
+
export function mergeSignalingRelayUrls(userRelayUrls) {
|
|
25
|
+
const seen = new Set()
|
|
26
|
+
/** @type {string[]} */
|
|
27
|
+
const merged = []
|
|
28
|
+
for (const url of [...DEFAULT_RELAY_URLS, ...Array.isArray(userRelayUrls) ? userRelayUrls : []]) {
|
|
29
|
+
const trimmed = String(url || '').trim()
|
|
30
|
+
if (!trimmed || seen.has(trimmed)) continue
|
|
31
|
+
seen.add(trimmed)
|
|
32
|
+
merged.push(trimmed)
|
|
33
|
+
}
|
|
34
|
+
return merged.length ? merged : [...DEFAULT_RELAY_URLS]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 字节数组转十六进制字符串。
|
|
39
|
+
* @param {Uint8Array} bytes 输入字节
|
|
40
|
+
* @returns {string} 小写 hex 字符串
|
|
41
|
+
*/
|
|
42
|
+
function bytesToHex(bytes) {
|
|
43
|
+
return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 十六进制字符串转字节数组。
|
|
48
|
+
* @param {string} hex 输入 hex 字符串
|
|
49
|
+
* @returns {Uint8Array} 解码后的字节
|
|
50
|
+
*/
|
|
51
|
+
function hexToBytes(hex) {
|
|
52
|
+
const normalized = String(hex || '').trim().toLowerCase()
|
|
53
|
+
const out = new Uint8Array(Math.floor(normalized.length / 2))
|
|
54
|
+
for (let index = 0; index < out.length; index++)
|
|
55
|
+
out[index] = parseInt(normalized.slice(index * 2, index * 2 + 2), 16)
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 字节数组转 base64 字符串。
|
|
61
|
+
* @param {Uint8Array} bytes 输入字节
|
|
62
|
+
* @returns {string} base64 编码
|
|
63
|
+
*/
|
|
64
|
+
function bytesToBase64(bytes) {
|
|
65
|
+
return btoa(String.fromCharCode(...bytes))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* base64 字符串转字节数组。
|
|
70
|
+
* @param {string} base64 输入 base64 字符串
|
|
71
|
+
* @returns {Uint8Array} 解码后的字节
|
|
72
|
+
*/
|
|
73
|
+
function base64ToBytes(base64) {
|
|
74
|
+
return Uint8Array.from(atob(base64).split('').map(ch => ch.charCodeAt(0)))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 签名 Nostr 事件。
|
|
79
|
+
* @param {number} kind 事件 kind
|
|
80
|
+
* @param {string[][]} tags 事件标签
|
|
81
|
+
* @param {string} content 事件内容
|
|
82
|
+
* @param {Uint8Array} secretKey Schnorr 私钥
|
|
83
|
+
* @returns {Promise<object>} 已签名的 Nostr 事件对象
|
|
84
|
+
*/
|
|
85
|
+
async function signNostrEvent(kind, tags, content, secretKey) {
|
|
86
|
+
const pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
|
|
87
|
+
const created_at = Math.floor(Date.now() / 1000)
|
|
88
|
+
const serialized = JSON.stringify([0, pubkey, created_at, kind, tags, content])
|
|
89
|
+
const id = sha256Hex(serialized)
|
|
90
|
+
const sig = bytesToHex(await schnorr.sign(hexToBytes(id), secretKey))
|
|
91
|
+
return { id, pubkey, created_at, kind, tags, content, sig }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 连接 Nostr 中继 WebSocket。
|
|
96
|
+
* @param {string} relayUrl 中继 URL
|
|
97
|
+
* @returns {Promise<WebSocket>} 已打开的 WebSocket
|
|
98
|
+
*/
|
|
99
|
+
function connectRelay(relayUrl) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const ws = new WebSocket(relayUrl)
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
try { ws.close() } catch { /* ignore */ }
|
|
104
|
+
reject(new Error(`nostr: connect timeout for ${relayUrl}`))
|
|
105
|
+
}, 10_000)
|
|
106
|
+
ws.addEventListener('open', () => {
|
|
107
|
+
clearTimeout(timer)
|
|
108
|
+
resolve(ws)
|
|
109
|
+
}, { once: true })
|
|
110
|
+
ws.addEventListener('error', () => {
|
|
111
|
+
clearTimeout(timer)
|
|
112
|
+
reject(new Error(`nostr: websocket error for ${relayUrl}`))
|
|
113
|
+
}, { once: true })
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 向多个中继发布 Nostr 事件。
|
|
119
|
+
* @param {string[]} relayUrls 中继 URL 列表
|
|
120
|
+
* @param {object} event 待发布事件
|
|
121
|
+
* @returns {Promise<void>}
|
|
122
|
+
*/
|
|
123
|
+
async function publishEvent(relayUrls, event) {
|
|
124
|
+
let published = false
|
|
125
|
+
let lastError = null
|
|
126
|
+
await Promise.allSettled(relayUrls.map(async relayUrl => {
|
|
127
|
+
const ws = await connectRelay(relayUrl)
|
|
128
|
+
try {
|
|
129
|
+
ws.send(JSON.stringify(['EVENT', event]))
|
|
130
|
+
published = true
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
lastError = error
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
setTimeout(() => { try { ws.close() } catch { /* ignore */ } }, 250)
|
|
138
|
+
}
|
|
139
|
+
}))
|
|
140
|
+
if (!published) throw lastError || new Error('nostr: no relay accepted publish')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 创建 Nostr discovery provider。
|
|
145
|
+
* @param {{ relayUrls?: string[] }} [opts] 可选中继 URL 覆盖
|
|
146
|
+
* @returns {import('./index.mjs').DiscoveryProvider} Nostr 发现提供者
|
|
147
|
+
*/
|
|
148
|
+
export function createNostrDiscoveryProvider(opts = {}) {
|
|
149
|
+
const relayUrls = mergeSignalingRelayUrls(opts.relayUrls)
|
|
150
|
+
const secretKey = randomBytes(32)
|
|
151
|
+
return {
|
|
152
|
+
id: 'nostr',
|
|
153
|
+
priority: 100,
|
|
154
|
+
caps: { canDiscover: true, canSignal: true, canRelay: false },
|
|
155
|
+
/**
|
|
156
|
+
* 周期性向中继发布 advert 事件。
|
|
157
|
+
* @param {string} topic advert 主题
|
|
158
|
+
* @param {Uint8Array} bytes advert 载荷
|
|
159
|
+
* @returns {Promise<() => void>} 取消广播函数
|
|
160
|
+
*/
|
|
161
|
+
async advertise(topic, bytes) {
|
|
162
|
+
let closed = false
|
|
163
|
+
/**
|
|
164
|
+
* 向中继发布当前 advert。
|
|
165
|
+
* @returns {Promise<void>}
|
|
166
|
+
*/
|
|
167
|
+
const publish = async () => {
|
|
168
|
+
if (closed) return
|
|
169
|
+
const event = await signNostrEvent(
|
|
170
|
+
NOSTR_ADVERT_KIND,
|
|
171
|
+
[['t', topic], ['x', 'advert']],
|
|
172
|
+
bytesToBase64(bytes),
|
|
173
|
+
secretKey,
|
|
174
|
+
)
|
|
175
|
+
await publishEvent(relayUrls, event)
|
|
176
|
+
}
|
|
177
|
+
await publish()
|
|
178
|
+
const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
|
|
179
|
+
return () => {
|
|
180
|
+
closed = true
|
|
181
|
+
clearInterval(timer)
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
/**
|
|
185
|
+
* 订阅中继上的 advert 事件。
|
|
186
|
+
* @param {string} topic advert 主题
|
|
187
|
+
* @param {Function} onAdvert advert 回调
|
|
188
|
+
* @returns {Promise<() => void>} 取消订阅函数
|
|
189
|
+
*/
|
|
190
|
+
async subscribe(topic, onAdvert) {
|
|
191
|
+
let closed = false
|
|
192
|
+
const sockets = []
|
|
193
|
+
const subscriptionId = randomBytes(8).toString('hex')
|
|
194
|
+
let connected = 0
|
|
195
|
+
for (const relayUrl of relayUrls) {
|
|
196
|
+
let ws
|
|
197
|
+
try { ws = await connectRelay(relayUrl) }
|
|
198
|
+
catch { continue }
|
|
199
|
+
connected++
|
|
200
|
+
sockets.push(ws)
|
|
201
|
+
ws.addEventListener('message', event => {
|
|
202
|
+
if (closed) return
|
|
203
|
+
let parsed
|
|
204
|
+
try { parsed = JSON.parse(String(event.data)) } catch { return }
|
|
205
|
+
if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
|
|
206
|
+
const nostrEvent = parsed[2]
|
|
207
|
+
if (nostrEvent?.kind !== NOSTR_ADVERT_KIND) return
|
|
208
|
+
try { onAdvert(base64ToBytes(String(nostrEvent.content || '')), { relayUrl, event: nostrEvent }) }
|
|
209
|
+
catch { /* ignore */ }
|
|
210
|
+
})
|
|
211
|
+
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_ADVERT_KIND], '#t': [topic], '#x': ['advert'] }]))
|
|
212
|
+
}
|
|
213
|
+
if (!connected) throw new Error('nostr: no relay available for advert subscribe')
|
|
214
|
+
return () => {
|
|
215
|
+
closed = true
|
|
216
|
+
for (const ws of sockets)
|
|
217
|
+
try { ws.close() } catch { /* ignore */ }
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
/**
|
|
221
|
+
* 向中继发布 signal 事件。
|
|
222
|
+
* @param {string} topic 信令 topic
|
|
223
|
+
* @param {string} to 目标节点标识
|
|
224
|
+
* @param {Uint8Array} bytes 信令载荷
|
|
225
|
+
* @returns {Promise<void>}
|
|
226
|
+
*/
|
|
227
|
+
async sendSignal(topic, to, bytes) {
|
|
228
|
+
const event = await signNostrEvent(
|
|
229
|
+
NOSTR_SIGNAL_KIND,
|
|
230
|
+
[['t', topic], ['x', 'signal'], ['p', String(to || '')]],
|
|
231
|
+
bytesToBase64(bytes),
|
|
232
|
+
secretKey,
|
|
233
|
+
)
|
|
234
|
+
await publishEvent(relayUrls, event)
|
|
235
|
+
},
|
|
236
|
+
/**
|
|
237
|
+
* 订阅中继上的 signal 事件。
|
|
238
|
+
* @param {string} topic 信令 topic
|
|
239
|
+
* @param {Function} onSignal 信令回调
|
|
240
|
+
* @returns {Promise<() => void>} 取消订阅函数
|
|
241
|
+
*/
|
|
242
|
+
async onSignal(topic, onSignal) {
|
|
243
|
+
let closed = false
|
|
244
|
+
const sockets = []
|
|
245
|
+
const subscriptionId = randomBytes(8).toString('hex')
|
|
246
|
+
let connected = 0
|
|
247
|
+
for (const relayUrl of relayUrls) {
|
|
248
|
+
let ws
|
|
249
|
+
try { ws = await connectRelay(relayUrl) }
|
|
250
|
+
catch { continue }
|
|
251
|
+
connected++
|
|
252
|
+
sockets.push(ws)
|
|
253
|
+
ws.addEventListener('message', event => {
|
|
254
|
+
if (closed) return
|
|
255
|
+
let parsed
|
|
256
|
+
try { parsed = JSON.parse(String(event.data)) } catch { return }
|
|
257
|
+
if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
|
|
258
|
+
const nostrEvent = parsed[2]
|
|
259
|
+
if (nostrEvent?.kind !== NOSTR_SIGNAL_KIND) return
|
|
260
|
+
try { onSignal(base64ToBytes(String(nostrEvent.content || '')), { relayUrl, event: nostrEvent }) }
|
|
261
|
+
catch { /* ignore */ }
|
|
262
|
+
})
|
|
263
|
+
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_SIGNAL_KIND], '#t': [topic], '#x': ['signal'] }]))
|
|
264
|
+
}
|
|
265
|
+
if (!connected) throw new Error('nostr: no relay available for signal subscribe')
|
|
266
|
+
return () => {
|
|
267
|
+
closed = true
|
|
268
|
+
for (const ws of sockets)
|
|
269
|
+
try { ws.close() } catch { /* ignore */ }
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isLogicalEntityHash } from '../logical_entity.mjs'
|
|
2
|
+
import { isWritableLocalEntity } from '../replica.mjs'
|
|
3
|
+
|
|
4
|
+
import { checkManifestAcl, resolveManifestAclType } from './manifest_acl_registry.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} replicaUsername 请求 replica
|
|
8
|
+
* @param {string} ownerEntityHash 文件 owner
|
|
9
|
+
* @param {import('../../files/manifest.mjs').FileManifest} manifest 清单
|
|
10
|
+
* @returns {Promise<boolean>} 是否允许读
|
|
11
|
+
*/
|
|
12
|
+
export async function canReadManifest(replicaUsername, ownerEntityHash, manifest) {
|
|
13
|
+
const type = resolveManifestAclType(manifest, ownerEntityHash)
|
|
14
|
+
if (type)
|
|
15
|
+
return checkManifestAcl(type, { replicaUsername, ownerEntityHash, manifest })
|
|
16
|
+
|
|
17
|
+
if (isLogicalEntityHash(ownerEntityHash))
|
|
18
|
+
return false
|
|
19
|
+
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} replicaUsername 请求 replica
|
|
25
|
+
* @param {string} ownerEntityHash 所有者
|
|
26
|
+
* @param {string} logicalPath 路径
|
|
27
|
+
* @returns {Promise<boolean>} 是否允许写
|
|
28
|
+
*/
|
|
29
|
+
export async function canWriteManifestPath(replicaUsername, ownerEntityHash, logicalPath) {
|
|
30
|
+
const type = resolveManifestAclType(null, ownerEntityHash)
|
|
31
|
+
if (type)
|
|
32
|
+
return checkManifestAcl(type, { replicaUsername, ownerEntityHash, manifest: /** @type {any} */ {} }, logicalPath)
|
|
33
|
+
|
|
34
|
+
if (isLogicalEntityHash(ownerEntityHash))
|
|
35
|
+
return false
|
|
36
|
+
|
|
37
|
+
return isWritableLocalEntity(ownerEntityHash)
|
|
38
|
+
}
|