@steve02081504/fount-p2p 0.0.33 → 0.0.34
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/discovery/nostr.mjs +10 -3
- package/link/frame.mjs +24 -2
- package/link/pipe.mjs +6 -2
- package/link/providers/nostr.mjs +154 -11
- package/package.json +1 -1
package/discovery/nostr.mjs
CHANGED
|
@@ -25,6 +25,10 @@ export const DEFAULT_RELAY_URLS = [
|
|
|
25
25
|
'wss://relay.damus.io',
|
|
26
26
|
'wss://nos.lol',
|
|
27
27
|
'wss://relay.nostr.band',
|
|
28
|
+
'wss://relay.nostr.com',
|
|
29
|
+
'wss://nostr.bitcoiner.social',
|
|
30
|
+
'wss://nostr.mom',
|
|
31
|
+
'wss://relay.snort.social',
|
|
28
32
|
]
|
|
29
33
|
|
|
30
34
|
/** 单中继 WebSocket 首连超时(短超时 + 并行,避免串行 10s×N)。 */
|
|
@@ -44,6 +48,9 @@ export const NOSTR_ADVERT_KIND = 30787
|
|
|
44
48
|
/** Nostr signal 事件 kind(ephemeral,实时转发)。 */
|
|
45
49
|
export const NOSTR_SIGNAL_KIND = 20787
|
|
46
50
|
|
|
51
|
+
/** 打广告用的话题 tag(hashtag,NIP-01),公开可被搜索聚合。 */
|
|
52
|
+
const NOSTR_TOPIC_TAG = ['t', 'fount']
|
|
53
|
+
|
|
47
54
|
const ADVERT_TTL_MS = 10 * 60_000
|
|
48
55
|
|
|
49
56
|
/** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
|
|
@@ -925,7 +932,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
925
932
|
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
926
933
|
const event = await signNostrEvent(
|
|
927
934
|
NOSTR_ADVERT_KIND,
|
|
928
|
-
[['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
935
|
+
[NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
929
936
|
bytesToBase64(bytes),
|
|
930
937
|
secretKey,
|
|
931
938
|
)
|
|
@@ -957,7 +964,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
957
964
|
const rendezvousKey = nodeRendezvousKey(hash)
|
|
958
965
|
const event = await signNostrEvent(
|
|
959
966
|
NOSTR_SIGNAL_KIND,
|
|
960
|
-
[['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
|
|
967
|
+
[NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
|
|
961
968
|
bytesToBase64(bytes),
|
|
962
969
|
secretKey,
|
|
963
970
|
)
|
|
@@ -1019,7 +1026,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
1019
1026
|
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
1020
1027
|
const event = await signNostrEvent(
|
|
1021
1028
|
NOSTR_ADVERT_KIND,
|
|
1022
|
-
[['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
1029
|
+
[NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
1023
1030
|
bytesToBase64(bytes),
|
|
1024
1031
|
secretKey,
|
|
1025
1032
|
)
|
package/link/frame.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
2
|
|
|
3
|
-
import { bytesToHex, hexToBytes, toBytes } from '../core/bytes_codec.mjs'
|
|
3
|
+
import { bytesToBase64, bytesToHex, hexToBytes, toBytes } from '../core/bytes_codec.mjs'
|
|
4
4
|
|
|
5
5
|
/** frameId 字段字节长度(128 位)。 */
|
|
6
6
|
export const FRAME_ID_BYTES = 16
|
|
@@ -40,6 +40,28 @@ export function randomFrameIdHex() {
|
|
|
40
40
|
return bytesToHex(randomBytes(FRAME_ID_BYTES))
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* 在单包字符上限下,求单片最大可承载 chunk 字节数。
|
|
45
|
+
* 以 base64 逐片精确贴合上限为目标,用二分在 [0, limit] 内找最大 chunkBytes,
|
|
46
|
+
* 使 base64Len(headerBytes + chunkBytes) <= limit —— 最大化单帧载荷利用率,
|
|
47
|
+
* 而不是按 base64 膨胀预留固定比例(预留比例会浪费余量且对帧头/填充不精确)。
|
|
48
|
+
* @param {number} maxPayloadChars 单包载荷字符上限(编码后)
|
|
49
|
+
* @param {(bytes: Uint8Array) => string} [encode] 载荷编码函数(默认 base64)
|
|
50
|
+
* @param {number} [headerBytes=FRAME_HEADER_BYTES] 帧头字节数
|
|
51
|
+
* @returns {number} 最大 chunk 字节数(>=0)
|
|
52
|
+
*/
|
|
53
|
+
export function maxFrameChunkBytesForPayload(maxPayloadChars, encode = bytesToBase64, headerBytes = FRAME_HEADER_BYTES) {
|
|
54
|
+
const limit = Math.max(1, Math.floor(Number(maxPayloadChars) || 0))
|
|
55
|
+
let lowerBound = 0
|
|
56
|
+
let upperBound = limit
|
|
57
|
+
while (lowerBound < upperBound) {
|
|
58
|
+
const candidateChunkBytes = Math.ceil((lowerBound + upperBound + 1) / 2)
|
|
59
|
+
if (encode(new Uint8Array(headerBytes + candidateChunkBytes)).length <= limit) lowerBound = candidateChunkBytes
|
|
60
|
+
else upperBound = candidateChunkBytes - 1
|
|
61
|
+
}
|
|
62
|
+
return lowerBound
|
|
63
|
+
}
|
|
64
|
+
|
|
43
65
|
/**
|
|
44
66
|
* 将消息切成带帧头的分片。
|
|
45
67
|
* @param {string | Uint8Array} frameId 消息 id(hex 或 16 字节)
|
|
@@ -50,7 +72,7 @@ export function randomFrameIdHex() {
|
|
|
50
72
|
export function encodeFrames(frameId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHUNK_BYTES) {
|
|
51
73
|
const body = toBytes(bytes)
|
|
52
74
|
const idBytes = normalizeFrameIdBytes(frameId)
|
|
53
|
-
const chunkBytes = Math.
|
|
75
|
+
const chunkBytes = Math.min(DEFAULT_MAX_MESSAGE_BYTES, Number(maxChunkBytes) || DEFAULT_MAX_FRAME_CHUNK_BYTES)
|
|
54
76
|
const total = Math.max(1, Math.ceil(body.byteLength / chunkBytes))
|
|
55
77
|
/** @type {Uint8Array[]} */
|
|
56
78
|
const frames = []
|
package/link/pipe.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { ms } from '../utils/duration.mjs'
|
|
|
4
4
|
import { emitSafe } from '../utils/emit_safe.mjs'
|
|
5
5
|
import { createLruMap } from '../utils/lru.mjs'
|
|
6
6
|
|
|
7
|
-
import { createReassembler, encodeFrames, randomFrameIdHex } from './frame.mjs'
|
|
7
|
+
import { createReassembler, DEFAULT_MAX_FRAME_CHUNK_BYTES, encodeFrames, FRAME_HEADER_BYTES, randomFrameIdHex } from './frame.mjs'
|
|
8
8
|
import { buildAuth, buildHello, parseHello, verifyAuth } from './handshake.mjs'
|
|
9
9
|
|
|
10
10
|
const encoder = new TextEncoder()
|
|
@@ -81,10 +81,14 @@ export function asLinkHandle(pipe, extras = {}) {
|
|
|
81
81
|
* @param {number} [options.idleTimeoutMs] 空闲超时
|
|
82
82
|
* @param {number} [options.handshakeTimeoutMs] 握手超时
|
|
83
83
|
* @param {number} [options.rttWindowSize] RTT 样本滑动窗口大小
|
|
84
|
+
* @param {number} [options.maxFrameBytes] 单帧最大字节数(provider 按其传输载荷上限折算;默认 15 KiB)
|
|
84
85
|
* @returns {object} link 句柄 + 入站 API
|
|
85
86
|
*/
|
|
86
87
|
export function createLinkPipe(options) {
|
|
87
88
|
const { providerId, level } = options
|
|
89
|
+
const maxFrameBytes = options.maxFrameBytes == null ? DEFAULT_MAX_FRAME_CHUNK_BYTES : Number(options.maxFrameBytes)
|
|
90
|
+
if (maxFrameBytes < FRAME_HEADER_BYTES)
|
|
91
|
+
throw new Error(`p2p: ${providerId} maxFrameBytes too small to carry a frame header`)
|
|
88
92
|
const heartbeatMs = Number(options.heartbeatMs) || ms('15s')
|
|
89
93
|
const idleTimeoutMs = Number(options.idleTimeoutMs) || ms('45s')
|
|
90
94
|
const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
|
|
@@ -348,7 +352,7 @@ export function createLinkPipe(options) {
|
|
|
348
352
|
payload: envelope.payload ?? null,
|
|
349
353
|
frameId,
|
|
350
354
|
}))
|
|
351
|
-
for (const frame of encodeFrames(frameId, bytes)) {
|
|
355
|
+
for (const frame of encodeFrames(frameId, bytes, maxFrameBytes)) {
|
|
352
356
|
const sent = options.sendFrame(envelope.action, frame)
|
|
353
357
|
if (sent?.then) await sent
|
|
354
358
|
sentFrames++
|
package/link/providers/nostr.mjs
CHANGED
|
@@ -4,16 +4,102 @@ import { randomBytes } from 'node:crypto'
|
|
|
4
4
|
import { base64ToBytes, bytesToBase64 } from '../../core/bytes_codec.mjs'
|
|
5
5
|
import { normalizeHex64 } from '../../core/hexIds.mjs'
|
|
6
6
|
import { getDiscoveryProvider, sendNodeSignalPacket } from '../../discovery/index.mjs'
|
|
7
|
-
import { resolveNostrRelayUrls } from '../../discovery/nostr.mjs'
|
|
7
|
+
import { NOSTR_SIGNAL_KIND, resolveNostrRelayUrls } from '../../discovery/nostr.mjs'
|
|
8
8
|
import { ms } from '../../utils/duration.mjs'
|
|
9
9
|
import { createLruMap } from '../../utils/lru.mjs'
|
|
10
|
+
import { FRAME_HEADER_BYTES, maxFrameChunkBytesForPayload } from '../frame.mjs'
|
|
10
11
|
import { asLinkHandle } from '../pipe.mjs'
|
|
11
12
|
|
|
12
13
|
import { LINK_LEVEL_NOSTR } from './levels.mjs'
|
|
13
14
|
import { createLinkIdBoundPipe } from './link_id_pipe.mjs'
|
|
14
15
|
|
|
15
|
-
/** 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。
|
|
16
|
-
|
|
16
|
+
/** 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。
|
|
17
|
+
* 默认兜底取 2026-08 本机对默认公共 relay 的 NIP-11 `max_message_length` 非零最小值(131072 = nostr.mom)。
|
|
18
|
+
* 有 relay 信息时用实测非零最小值覆盖(见 refreshPayloadCap),无 relay / 未探测到时用此默认。 */
|
|
19
|
+
export const MAX_LINK_PAYLOAD_CHARS = 131072
|
|
20
|
+
|
|
21
|
+
/** relay info(NIP-11)单次探测超时。 */
|
|
22
|
+
const RELAY_INFO_TIMEOUT_MS = ms('4s')
|
|
23
|
+
/** 实测 payload 上限缓存有效期。 */
|
|
24
|
+
const PAYLOAD_CAP_CACHE_TTL_MS = ms('10m')
|
|
25
|
+
const textEncoder = new TextEncoder()
|
|
26
|
+
/** 固定 64 位 hex 占位(id/pubkey/sig/rendezvousKey/nodeHash 均固定宽度)。 */
|
|
27
|
+
const HEX64 = 'a'.repeat(64)
|
|
28
|
+
/** AES-GCM 封装输出中的固定长度字段占位:iv = base64(12B) = 16,authTag = base64(16B) = 24。 */
|
|
29
|
+
const GCM_IV_BASE64 = 'a'.repeat(16)
|
|
30
|
+
const GCM_AUTH_TAG_BASE64 = 'a'.repeat(24)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 估算把给定 link 包发布为 Nostr EVENT 后,完整 WebSocket 消息(["EVENT", event])的 UTF-8 字节长度。
|
|
34
|
+
* id/pubkey/sig/rendezvousKey/nodeHash 均为固定 64 hex,created_at/kind/tags 固定,故长度仅随 packet.payload 变化,
|
|
35
|
+
* 可同步精确构造(含加密封装壳与 event 字段),无需真正 AES-GCM 封装与 Schnorr 签名。
|
|
36
|
+
* @param {object} packet link 包
|
|
37
|
+
* @returns {number} 完整消息字节长度
|
|
38
|
+
*/
|
|
39
|
+
export function estimateEventMessageBytes(packet) {
|
|
40
|
+
// encryptSignalPacket 输出全 ASCII:{"iv":<16>,"authTag":<24>,"ciphertext":base64(packetJson bytes)}。
|
|
41
|
+
return textEncoder.encode(JSON.stringify(['EVENT', {
|
|
42
|
+
id: HEX64,
|
|
43
|
+
pubkey: HEX64,
|
|
44
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
45
|
+
kind: NOSTR_SIGNAL_KIND,
|
|
46
|
+
tags: [['t', HEX64], ['x', 'signal'], ['p', HEX64]],
|
|
47
|
+
content: bytesToBase64(textEncoder.encode(JSON.stringify({
|
|
48
|
+
iv: GCM_IV_BASE64,
|
|
49
|
+
authTag: GCM_AUTH_TAG_BASE64,
|
|
50
|
+
ciphertext: bytesToBase64(textEncoder.encode(JSON.stringify(packet))),
|
|
51
|
+
}))),
|
|
52
|
+
sig: HEX64,
|
|
53
|
+
}])).length
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** relay cap 低于此字符数视为无法承载最小正 chunk(帧头 + 1 字节 chunk 的完整 EVENT 封装),从统一上限中剔除。
|
|
57
|
+
* 这类 relay 即便能传也无法携带有效载荷(maxFrameChunkBytesForPayload 得 0),参与取最小值只会无谓拖低/毒化整条链路。 */
|
|
58
|
+
export const MIN_USABLE_RELAY_CAP_CHARS = estimateEventMessageBytes({
|
|
59
|
+
type: 'link',
|
|
60
|
+
op: 'b',
|
|
61
|
+
from: HEX64,
|
|
62
|
+
linkId: HEX64,
|
|
63
|
+
payload: bytesToBase64(new Uint8Array(FRAME_HEADER_BYTES + 1)),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 拉取单个 relay 的 NIP-11 relay info 并读取 max_message_length。
|
|
68
|
+
* @param {string} relayUrl relay URL
|
|
69
|
+
* @returns {Promise<number | null>} 非零上限;失败/未声明返回 null
|
|
70
|
+
*/
|
|
71
|
+
async function queryRelayMaxMessageLength(relayUrl) {
|
|
72
|
+
const httpUrl = relayUrl.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:')
|
|
73
|
+
const controller = new AbortController()
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), RELAY_INFO_TIMEOUT_MS)
|
|
75
|
+
timer.unref?.()
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(httpUrl, { headers: { Accept: 'application/nostr+json' }, signal: controller.signal })
|
|
78
|
+
const info = await response.json()
|
|
79
|
+
const limit = { ...info?.limit || {}, ...info?.limitation || {} }
|
|
80
|
+
const value = Number(limit.max_message_length)
|
|
81
|
+
return Number.isFinite(value) && value > 0 ? value : null
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 从各 relay 上报的 cap 中取「可用」(>= 最小可用帧,能产生正数 chunk budget)的非零最小值。
|
|
93
|
+
* cap 过低的 relay 无法承载最小正 chunk(完整 EVENT 封装装不下),取最小值只会拖低/毒化整条链路,故剔除;
|
|
94
|
+
* 剩余 relay 的最小值保证任意一个可用 relay 都能传该帧(publishEvent 只要求任一 relay 接受)。
|
|
95
|
+
* @param {Array<number | null | undefined>} caps 各 relay 上报的 cap
|
|
96
|
+
* @returns {number | null} 可用最小值;无可用 relay 返回 null
|
|
97
|
+
*/
|
|
98
|
+
export function minUsablePayloadCap(caps) {
|
|
99
|
+
const usable = caps.filter(value => Number.isFinite(value) && value >= MIN_USABLE_RELAY_CAP_CHARS)
|
|
100
|
+
return usable.length ? Math.min(...usable) : null
|
|
101
|
+
}
|
|
102
|
+
|
|
17
103
|
/** open 到达前为同一 linkId 暂存的 c/b 包上限。 */
|
|
18
104
|
const PENDING_PACKETS_MAX = 32
|
|
19
105
|
/** Nostr 链握手超时(relay RTT 更慢)。 */
|
|
@@ -35,11 +121,49 @@ async function publishLinkPacket(remoteNodeHash, packet) {
|
|
|
35
121
|
/**
|
|
36
122
|
* 创建 Nostr 末位数据链路 provider(level = -∞)。
|
|
37
123
|
* @param {{ getRelayUrls?: () => string[] }} [options] 中继解析(测试可注入)
|
|
38
|
-
|
|
124
|
+
* @returns {import('./index.mjs').LinkProvider & { deliverPacket: (packet: object) => void | Promise<void> }} provider
|
|
39
125
|
*/
|
|
40
126
|
export function createNostrLinkProvider(options = {}) {
|
|
41
127
|
const resolveRelayUrls = options.getRelayUrls || resolveNostrRelayUrls
|
|
42
128
|
|
|
129
|
+
/** @type {number | null} 本实例实测非零最小 payload 上限(NIP-11 max_message_length) */
|
|
130
|
+
let queriedPayloadCap = null
|
|
131
|
+
/** @type {number} 本实例最近一次实测时间戳 */
|
|
132
|
+
let payloadCapQueriedAt = 0
|
|
133
|
+
/** @type {Promise<void> | null} 本实例进行中的探测(去重并发) */
|
|
134
|
+
let payloadCapRefreshPromise = null
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 探测各 relay 的 payload 上限,缓存「可用 relay」的最小值(TTL 内不重复探测)。
|
|
138
|
+
* 仅在实际 wss/ws/https/http 中继上探测;失败或未声明的 relay 忽略。
|
|
139
|
+
* @param {string[]} relayUrls 中继 URL 列表
|
|
140
|
+
* @returns {Promise<void>}
|
|
141
|
+
*/
|
|
142
|
+
async function refreshPayloadCap(relayUrls) {
|
|
143
|
+
if (payloadCapRefreshPromise) return payloadCapRefreshPromise
|
|
144
|
+
if (queriedPayloadCap != null && Date.now() - payloadCapQueriedAt < PAYLOAD_CAP_CACHE_TTL_MS) return
|
|
145
|
+
const urls = [...new Set(relayUrls.filter(url => /^(wss?|https?):\/\//i.test(url)))]
|
|
146
|
+
if (!urls.length) return
|
|
147
|
+
payloadCapRefreshPromise = Promise.allSettled(urls.map(queryRelayMaxMessageLength))
|
|
148
|
+
.then(results => {
|
|
149
|
+
const value = minUsablePayloadCap(results.map(result => result.status === 'fulfilled' ? result.value : null))
|
|
150
|
+
if (value != null) {
|
|
151
|
+
queriedPayloadCap = value
|
|
152
|
+
payloadCapQueriedAt = Date.now()
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
.finally(() => { payloadCapRefreshPromise = null })
|
|
156
|
+
return payloadCapRefreshPromise
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 当前生效的单包上限:有 relay 实测非零最小值则用它(最大化载荷利用率),否则用默认兜底。
|
|
161
|
+
* @returns {number} 上限(针对完整 Nostr EVENT 消息的字节数)
|
|
162
|
+
*/
|
|
163
|
+
function currentMaxPayloadChars() {
|
|
164
|
+
return queriedPayloadCap ?? MAX_LINK_PAYLOAD_CHARS
|
|
165
|
+
}
|
|
166
|
+
|
|
43
167
|
/** @type {((link: import('./index.mjs').LinkHandle) => void) | null} */
|
|
44
168
|
let onInbound = null
|
|
45
169
|
/** @type {object | null} */
|
|
@@ -109,11 +233,10 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
109
233
|
* @param {string} linkId 链路 id
|
|
110
234
|
* @param {string} op open|c|b|close
|
|
111
235
|
* @param {string} [payload] 可选载荷
|
|
236
|
+
* @param {number} [maxChars=MAX_LINK_PAYLOAD_CHARS] 该 pipe 冻结的字符上限
|
|
112
237
|
* @returns {Promise<void>}
|
|
113
238
|
*/
|
|
114
|
-
async function sendOp(remoteNodeHash, linkId, op, payload) {
|
|
115
|
-
if (payload != null && payload.length > MAX_LINK_PAYLOAD_CHARS)
|
|
116
|
-
throw new Error('p2p: nostr link payload too large')
|
|
239
|
+
async function sendOp(remoteNodeHash, linkId, op, payload, maxChars = MAX_LINK_PAYLOAD_CHARS) {
|
|
117
240
|
const packet = {
|
|
118
241
|
type: 'link',
|
|
119
242
|
op,
|
|
@@ -121,6 +244,8 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
121
244
|
linkId,
|
|
122
245
|
}
|
|
123
246
|
if (payload != null) packet.payload = payload
|
|
247
|
+
if (estimateEventMessageBytes(packet) > maxChars)
|
|
248
|
+
throw new Error('p2p: nostr link payload too large')
|
|
124
249
|
await publishLinkPacket(remoteNodeHash, packet)
|
|
125
250
|
}
|
|
126
251
|
|
|
@@ -130,6 +255,19 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
130
255
|
*/
|
|
131
256
|
function openPipe(opts) {
|
|
132
257
|
const { linkId, remoteNodeHash, initiator } = opts
|
|
258
|
+
// 冻结该 pipe 的字符上限:按完整 Nostr EVENT 消息字节数切帧,与发送校验共用同一上限,保持自洽。
|
|
259
|
+
const payloadChars = currentMaxPayloadChars()
|
|
260
|
+
const maxFrameBytes = maxFrameChunkBytesForPayload(
|
|
261
|
+
payloadChars,
|
|
262
|
+
// maxFrameChunkBytesForPayload 用 encode(...).length 度量载荷,故返回等长字符串。
|
|
263
|
+
frameBytes => 'x'.repeat(estimateEventMessageBytes({
|
|
264
|
+
type: 'link',
|
|
265
|
+
op: 'b',
|
|
266
|
+
from: (opts.localIdentity || localIdentity)?.nodeHash || '',
|
|
267
|
+
linkId,
|
|
268
|
+
payload: bytesToBase64(frameBytes),
|
|
269
|
+
})),
|
|
270
|
+
)
|
|
133
271
|
const pipe = createLinkIdBoundPipe({
|
|
134
272
|
providerId: 'nostr',
|
|
135
273
|
level: LINK_LEVEL_NOSTR,
|
|
@@ -137,6 +275,7 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
137
275
|
linkId,
|
|
138
276
|
nodeHash: remoteNodeHash,
|
|
139
277
|
localIdentity: opts.localIdentity || localIdentity,
|
|
278
|
+
maxFrameBytes,
|
|
140
279
|
handshakeTimeoutMs: NOSTR_HANDSHAKE_TIMEOUT_MS,
|
|
141
280
|
heartbeatMs: NOSTR_HEARTBEAT_MS,
|
|
142
281
|
idleTimeoutMs: NOSTR_IDLE_TIMEOUT_MS,
|
|
@@ -145,7 +284,7 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
145
284
|
* @returns {Promise<void>}
|
|
146
285
|
*/
|
|
147
286
|
async sendControlText(text) {
|
|
148
|
-
await sendOp(remoteNodeHash, linkId, 'c', text)
|
|
287
|
+
await sendOp(remoteNodeHash, linkId, 'c', text, payloadChars)
|
|
149
288
|
},
|
|
150
289
|
/**
|
|
151
290
|
* @param {string} _action action
|
|
@@ -153,7 +292,7 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
153
292
|
* @returns {Promise<void>}
|
|
154
293
|
*/
|
|
155
294
|
async sendFrame(_action, frame) {
|
|
156
|
-
await sendOp(remoteNodeHash, linkId, 'b', bytesToBase64(frame))
|
|
295
|
+
await sendOp(remoteNodeHash, linkId, 'b', bytesToBase64(frame), payloadChars)
|
|
157
296
|
},
|
|
158
297
|
/**
|
|
159
298
|
* @returns {Promise<void>}
|
|
@@ -174,9 +313,9 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
174
313
|
/**
|
|
175
314
|
* 入站已解密的 link 包(由信令 demux 调用)。
|
|
176
315
|
* @param {object} packet link 包
|
|
177
|
-
* @returns {void}
|
|
316
|
+
* @returns {Promise<void>}
|
|
178
317
|
*/
|
|
179
|
-
function deliverPacket(packet) {
|
|
318
|
+
async function deliverPacket(packet) {
|
|
180
319
|
if (packet?.type !== 'link') return
|
|
181
320
|
const linkId = normalizeHex64(packet.linkId)
|
|
182
321
|
const from = normalizeHex64(packet.from)
|
|
@@ -187,6 +326,8 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
187
326
|
if (op === 'open') {
|
|
188
327
|
if (sessions.has(linkId)) return
|
|
189
328
|
if (!onInbound || !localIdentity) return
|
|
329
|
+
await refreshPayloadCap(resolveRelayUrls())
|
|
330
|
+
if (sessions.has(linkId)) return
|
|
190
331
|
const pipe = openPipe({
|
|
191
332
|
linkId,
|
|
192
333
|
remoteNodeHash: from,
|
|
@@ -238,6 +379,7 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
238
379
|
if (!remoteNodeHash) return null
|
|
239
380
|
localIdentity = dialOptions.localIdentity || localIdentity
|
|
240
381
|
if (!localIdentity?.nodeHash) throw new Error('p2p: nostr dial requires localIdentity')
|
|
382
|
+
await refreshPayloadCap(resolveRelayUrls())
|
|
241
383
|
const linkId = randomBytes(32).toString('hex')
|
|
242
384
|
const pipe = openPipe({
|
|
243
385
|
linkId,
|
|
@@ -256,6 +398,7 @@ export function createNostrLinkProvider(options = {}) {
|
|
|
256
398
|
ensureListening(handlers) {
|
|
257
399
|
onInbound = handlers.onInbound
|
|
258
400
|
localIdentity = handlers.localIdentity
|
|
401
|
+
void refreshPayloadCap(resolveRelayUrls())
|
|
259
402
|
return () => {
|
|
260
403
|
onInbound = null
|
|
261
404
|
for (const session of sessions.values())
|