@steve02081504/fount-p2p 0.0.5 → 0.0.7
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/core/tcp_port.mjs +11 -0
- package/dag/canonicalize_row.mjs +5 -5
- package/discovery/advert_peer_hints.mjs +21 -0
- package/discovery/{bt.mjs → bt/index.mjs} +151 -55
- package/discovery/bt/peer_hints.mjs +41 -0
- package/discovery/bt/runtime.mjs +46 -0
- package/discovery/lan_peer_hints.mjs +43 -0
- package/discovery/mdns.mjs +10 -6
- package/discovery/nostr.mjs +3 -3
- package/federation/chunk_fetch_pending.mjs +3 -3
- package/federation/chunk_fetch_scheduler.mjs +3 -3
- package/federation/dag_order_cache.mjs +3 -3
- package/federation/topo_order_memo.mjs +3 -3
- package/files/chunk_responder.mjs +1 -1
- package/files/evfs.mjs +26 -26
- package/files/transfer_key.mjs +9 -9
- package/files/transfer_key_registry.mjs +9 -9
- package/governance/join_pow.mjs +17 -17
- package/index.mjs +3 -2
- package/link/channel_mux.mjs +7 -7
- package/link/frame.mjs +5 -5
- package/link/handshake.mjs +63 -36
- package/link/pipe.mjs +404 -0
- package/link/providers/ble_gatt.mjs +339 -0
- package/link/providers/index.mjs +90 -0
- package/link/providers/lan_tcp.mjs +342 -0
- package/link/providers/levels.mjs +9 -0
- package/link/providers/webrtc.mjs +385 -0
- package/mailbox/deliver_or_store.mjs +13 -13
- package/mailbox/wire.mjs +4 -4
- package/node/reputation_store.mjs +5 -5
- package/node/retention_policy.mjs +8 -8
- package/overlay/index.mjs +6 -6
- package/package.json +3 -2
- package/registries/inbound.mjs +8 -8
- package/rooms/scoped_link.mjs +23 -21
- package/timeline/append_core.mjs +3 -3
- package/transport/group_link_set.mjs +35 -33
- package/transport/link_registry.mjs +250 -63
- package/transport/peer_pool.mjs +4 -4
- package/transport/user_room.mjs +12 -14
- package/utils/ttl_map.mjs +41 -0
- package/wire/group_part.mjs +3 -3
- package/wire/part_ingress.mjs +14 -14
- package/wire/part_query.mjs +81 -82
- package/wire/part_query_cache.mjs +7 -0
- package/link/link.mjs +0 -617
package/link/pipe.mjs
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { normalizeHex64 } from '../core/hexIds.mjs'
|
|
2
|
+
import { ms } from '../utils/duration.mjs'
|
|
3
|
+
import { createLruMap } from '../utils/lru.mjs'
|
|
4
|
+
|
|
5
|
+
import { createReassembler, encodeFrames, randomMsgIdHex } from './frame.mjs'
|
|
6
|
+
import { buildAuth, buildHello, parseHello, verifyAuth } from './handshake.mjs'
|
|
7
|
+
|
|
8
|
+
const encoder = new TextEncoder()
|
|
9
|
+
const decoder = new TextDecoder()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 依次调用监听器集合,忽略单个 listener 抛错。
|
|
13
|
+
* @param {Set<Function>} listeners 监听器集合
|
|
14
|
+
* @param {...unknown} args 传递给 listener 的参数
|
|
15
|
+
* @returns {void}
|
|
16
|
+
*/
|
|
17
|
+
function emitListeners(listeners, ...args) {
|
|
18
|
+
for (const listener of listeners)
|
|
19
|
+
try { listener(...args) }
|
|
20
|
+
catch { /* ignore */ }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 原始字节:以 `{` 开头的 UTF-8 当 control 文本,否则当二进制帧。
|
|
25
|
+
* @param {Buffer | Uint8Array | string} raw 原始数据
|
|
26
|
+
* @returns {string | Uint8Array} control 文本或二进制帧
|
|
27
|
+
*/
|
|
28
|
+
export function coercePipeInbound(raw) {
|
|
29
|
+
if (typeof raw === 'string') return raw
|
|
30
|
+
const bytes = raw instanceof Uint8Array ? raw : Uint8Array.from(raw)
|
|
31
|
+
try {
|
|
32
|
+
const text = decoder.decode(bytes)
|
|
33
|
+
if (text.startsWith('{')) return text
|
|
34
|
+
}
|
|
35
|
+
catch { /* binary */ }
|
|
36
|
+
return bytes
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 把 createLinkPipe 句柄收成上层 LinkHandle(可附带测试/内部字段)。
|
|
41
|
+
* @param {ReturnType<typeof createLinkPipe>} pipe pipe
|
|
42
|
+
* @param {object} [extras] 附加字段(如 handleInbound、_channelForTest)
|
|
43
|
+
* @returns {object} LinkHandle 形状
|
|
44
|
+
*/
|
|
45
|
+
export function asLinkHandle(pipe, extras = {}) {
|
|
46
|
+
return {
|
|
47
|
+
ready: pipe.ready,
|
|
48
|
+
/** @returns {string | null} 对端 nodeHash */
|
|
49
|
+
get nodeHash() { return pipe.nodeHash },
|
|
50
|
+
/** @returns {boolean} 是否发起方 */
|
|
51
|
+
get initiator() { return pipe.initiator },
|
|
52
|
+
/** @returns {string} 提供者 id */
|
|
53
|
+
get providerId() { return pipe.providerId },
|
|
54
|
+
/** @returns {number} 提供者 level */
|
|
55
|
+
get level() { return pipe.level },
|
|
56
|
+
/**
|
|
57
|
+
* @param {...unknown} args send 参数
|
|
58
|
+
* @returns {Promise<boolean>} 是否发送成功
|
|
59
|
+
*/
|
|
60
|
+
send: (...args) => pipe.send(...args),
|
|
61
|
+
/**
|
|
62
|
+
* @param {...unknown} args onEnvelope 参数
|
|
63
|
+
* @returns {() => void} 取消订阅
|
|
64
|
+
*/
|
|
65
|
+
onEnvelope: (...args) => pipe.onEnvelope(...args),
|
|
66
|
+
/**
|
|
67
|
+
* @param {...unknown} args onDown 参数
|
|
68
|
+
* @returns {() => void} 取消订阅
|
|
69
|
+
*/
|
|
70
|
+
onDown: (...args) => pipe.onDown(...args),
|
|
71
|
+
/**
|
|
72
|
+
* @param {...unknown} args close 参数
|
|
73
|
+
* @returns {Promise<void>}
|
|
74
|
+
*/
|
|
75
|
+
close: (...args) => pipe.close(...args),
|
|
76
|
+
/** @returns {object} 运行时统计 */
|
|
77
|
+
stats: () => pipe.stats(),
|
|
78
|
+
...extras,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 在已打开的字节/控制双工上跑 hello/auth、分帧 envelope 与心跳。
|
|
84
|
+
* @param {object} options pipe 配置
|
|
85
|
+
* @param {string} options.providerId 提供者 id
|
|
86
|
+
* @param {number} options.level 提供者 level
|
|
87
|
+
* @param {boolean} options.initiator 是否发起方
|
|
88
|
+
* @param {string | null} [options.nodeHash] 期望对端 nodeHash
|
|
89
|
+
* @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array, nonce?: string } | null} [options.localIdentity] 本地身份
|
|
90
|
+
* @param {() => string | null} options.getLocalBinding 本地 binding(未就绪返回 null)
|
|
91
|
+
* @param {() => string | null} options.getRemoteBinding 远端 binding(未就绪返回 null)
|
|
92
|
+
* @param {(text: string) => void | Promise<void>} options.sendControlText 发送 control JSON 文本
|
|
93
|
+
* @param {(action: string, frame: Uint8Array) => void | Promise<void>} options.sendFrame 发送分帧二进制
|
|
94
|
+
* @param {() => void | Promise<void>} [options.closeTransport] 关闭底层传输
|
|
95
|
+
* @param {() => object} [options.extraStats] 附加 stats 字段
|
|
96
|
+
* @param {number} [options.heartbeatMs] 心跳间隔
|
|
97
|
+
* @param {number} [options.idleTimeoutMs] 空闲超时
|
|
98
|
+
* @param {number} [options.handshakeTimeoutMs] 握手超时
|
|
99
|
+
* @returns {object} link 句柄 + 入站 API
|
|
100
|
+
*/
|
|
101
|
+
export function createLinkPipe(options) {
|
|
102
|
+
const providerId = String(options.providerId || '')
|
|
103
|
+
const level = Number(options.level) || 0
|
|
104
|
+
const heartbeatMs = Number(options.heartbeatMs) || ms('15s')
|
|
105
|
+
const idleTimeoutMs = Number(options.idleTimeoutMs) || ms('45s')
|
|
106
|
+
const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
|
|
107
|
+
const targetNodeHash = normalizeHex64(options.nodeHash || '')
|
|
108
|
+
let closed = false
|
|
109
|
+
let ready = false
|
|
110
|
+
let closeReason = 'closed'
|
|
111
|
+
let remoteNodeHash = targetNodeHash || null
|
|
112
|
+
let remoteHello = null
|
|
113
|
+
let localHello = null
|
|
114
|
+
let handshakeTimer = null
|
|
115
|
+
let idleTimer = null
|
|
116
|
+
let heartbeatTimer = null
|
|
117
|
+
let helloSent = false
|
|
118
|
+
let authSent = false
|
|
119
|
+
let remoteAuthVerified = false
|
|
120
|
+
/** @type {object | null} */
|
|
121
|
+
let pendingAuth = null
|
|
122
|
+
let lastInboundAt = Date.now()
|
|
123
|
+
let lastOutboundAt = 0
|
|
124
|
+
let sentFrames = 0
|
|
125
|
+
let recvFrames = 0
|
|
126
|
+
const envelopeListeners = new Set()
|
|
127
|
+
const downListeners = new Set()
|
|
128
|
+
const completedMsgIds = createLruMap(4096)
|
|
129
|
+
const reassembler = createReassembler()
|
|
130
|
+
/** @type {(value: void | PromiseLike<void>) => void} */
|
|
131
|
+
let resolveReady
|
|
132
|
+
/** @type {(reason?: unknown) => void} */
|
|
133
|
+
let rejectReady
|
|
134
|
+
const readyPromise = new Promise((resolve, reject) => {
|
|
135
|
+
resolveReady = resolve
|
|
136
|
+
rejectReady = reject
|
|
137
|
+
})
|
|
138
|
+
void readyPromise.catch(() => { })
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 经 control 发送 hello/auth JSON。
|
|
142
|
+
* @param {object} body hello 或 auth 字段
|
|
143
|
+
* @returns {Promise<void>}
|
|
144
|
+
*/
|
|
145
|
+
async function sendRawControl(body) {
|
|
146
|
+
const text = JSON.stringify({ type: body.sig ? 'auth' : 'hello', ...body })
|
|
147
|
+
await Promise.resolve(options.sendControlText(text))
|
|
148
|
+
lastOutboundAt = Date.now()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* binding 就绪后向对端发送 auth。
|
|
153
|
+
* @returns {Promise<void>}
|
|
154
|
+
*/
|
|
155
|
+
async function maybeSendAuth() {
|
|
156
|
+
if (authSent || !remoteHello) return
|
|
157
|
+
const binding = options.getLocalBinding()
|
|
158
|
+
if (!binding) return
|
|
159
|
+
authSent = true
|
|
160
|
+
await sendRawControl(await buildAuth(remoteHello.nonce, binding, options.localIdentity ?? {}))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 握手完成后启动心跳并 resolve ready。
|
|
165
|
+
* @returns {Promise<void>}
|
|
166
|
+
*/
|
|
167
|
+
async function maybeFinishHandshake() {
|
|
168
|
+
if (ready || !remoteHello || !remoteAuthVerified) return
|
|
169
|
+
ready = true
|
|
170
|
+
clearTimeout(handshakeTimer)
|
|
171
|
+
heartbeatTimer = setInterval(() => {
|
|
172
|
+
void send({ scope: 'link', action: 'ping', payload: {} }).catch(() => { })
|
|
173
|
+
}, heartbeatMs)
|
|
174
|
+
idleTimer = setInterval(() => {
|
|
175
|
+
if (Date.now() - lastInboundAt > idleTimeoutMs)
|
|
176
|
+
void close('idle-timeout')
|
|
177
|
+
}, Math.max(1000, Math.floor(heartbeatMs / 3)))
|
|
178
|
+
resolveReady()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 校验远端 auth。
|
|
183
|
+
* @param {{ sig: string }} auth auth 载荷
|
|
184
|
+
* @returns {Promise<void>}
|
|
185
|
+
*/
|
|
186
|
+
async function handleAuth(auth) {
|
|
187
|
+
if (!remoteHello) {
|
|
188
|
+
pendingAuth = auth
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
const binding = options.getRemoteBinding()
|
|
192
|
+
const verifiedNodeHash = await verifyAuth(remoteHello, auth, localHello?.nonce, binding)
|
|
193
|
+
if (!verifiedNodeHash) {
|
|
194
|
+
await close(`auth-failed:binding=${binding || 'missing'} localNonce=${localHello?.nonce || 'missing'}`)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (targetNodeHash && verifiedNodeHash !== targetNodeHash) {
|
|
198
|
+
await close('nodehash-mismatch')
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
remoteNodeHash = verifiedNodeHash
|
|
202
|
+
remoteAuthVerified = true
|
|
203
|
+
await maybeFinishHandshake()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 处理 control JSON(hello/auth)。
|
|
208
|
+
* @param {unknown} message 解析后的对象
|
|
209
|
+
* @returns {Promise<void>}
|
|
210
|
+
*/
|
|
211
|
+
async function handleControlMessage(message) {
|
|
212
|
+
if (!message || typeof message !== 'object') return
|
|
213
|
+
if (message.type === 'hello') {
|
|
214
|
+
const parsed = parseHello(message)
|
|
215
|
+
if (!parsed) {
|
|
216
|
+
await close('hello-invalid')
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
remoteHello = parsed
|
|
220
|
+
await maybeSendAuth()
|
|
221
|
+
if (pendingAuth) {
|
|
222
|
+
const bufferedAuth = pendingAuth
|
|
223
|
+
pendingAuth = null
|
|
224
|
+
await handleAuth(bufferedAuth)
|
|
225
|
+
}
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
if (message.type === 'auth')
|
|
229
|
+
await handleAuth(message)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 处理入站二进制帧。
|
|
234
|
+
* @param {Uint8Array} bytes 帧字节
|
|
235
|
+
* @returns {void}
|
|
236
|
+
*/
|
|
237
|
+
function handleBinaryFrame(bytes) {
|
|
238
|
+
try {
|
|
239
|
+
recvFrames++
|
|
240
|
+
const merged = reassembler.push(bytes)
|
|
241
|
+
if (!merged) return
|
|
242
|
+
const envelope = JSON.parse(decoder.decode(merged))
|
|
243
|
+
const msgId = envelope?.msgId ? String(envelope.msgId) : null
|
|
244
|
+
if (msgId && completedMsgIds.has(msgId)) return
|
|
245
|
+
if (msgId) completedMsgIds.touch(msgId, true)
|
|
246
|
+
if (envelope?.scope === 'link') {
|
|
247
|
+
if (envelope.action === 'ping') {
|
|
248
|
+
void send({ scope: 'link', action: 'pong', payload: {} }).catch(() => { })
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
if (envelope.action === 'pong') return
|
|
252
|
+
}
|
|
253
|
+
if (ready && remoteNodeHash)
|
|
254
|
+
emitListeners(envelopeListeners, envelope, remoteNodeHash)
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
/* drop malformed network ingress */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 统一入站:JSON control 或二进制帧。
|
|
263
|
+
* @param {unknown} data 原始数据
|
|
264
|
+
* @returns {void}
|
|
265
|
+
*/
|
|
266
|
+
function handleInbound(data) {
|
|
267
|
+
lastInboundAt = Date.now()
|
|
268
|
+
if (typeof data === 'string') {
|
|
269
|
+
if (data.startsWith('{'))
|
|
270
|
+
try {
|
|
271
|
+
void handleControlMessage(JSON.parse(data))
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
catch { /* fall through */ }
|
|
275
|
+
try {
|
|
276
|
+
handleBinaryFrame(encoder.encode(data))
|
|
277
|
+
}
|
|
278
|
+
catch { /* ignore */ }
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || data instanceof Uint8Array) {
|
|
282
|
+
const bytes = data instanceof Uint8Array
|
|
283
|
+
? data
|
|
284
|
+
: data instanceof ArrayBuffer
|
|
285
|
+
? new Uint8Array(data)
|
|
286
|
+
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
287
|
+
// 尝试 UTF-8 JSON control
|
|
288
|
+
try {
|
|
289
|
+
const text = decoder.decode(bytes)
|
|
290
|
+
if (text.startsWith('{')) {
|
|
291
|
+
void handleControlMessage(JSON.parse(text))
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch { /* binary path */ }
|
|
296
|
+
handleBinaryFrame(bytes)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 传输就绪后启动握手(发 hello)。
|
|
302
|
+
* @returns {Promise<void>}
|
|
303
|
+
*/
|
|
304
|
+
async function startHandshake() {
|
|
305
|
+
if (helloSent || closed) return
|
|
306
|
+
handshakeTimer = setTimeout(() => { void close('handshake-timeout') }, handshakeTimeoutMs)
|
|
307
|
+
helloSent = true
|
|
308
|
+
localHello = buildHello(options.localIdentity ?? {})
|
|
309
|
+
await sendRawControl(localHello)
|
|
310
|
+
await maybeSendAuth()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 发送业务 envelope。
|
|
315
|
+
* @param {{ scope: string, action: string, payload: unknown }} envelope 信封
|
|
316
|
+
* @returns {Promise<boolean>} 是否发送成功
|
|
317
|
+
*/
|
|
318
|
+
async function send(envelope) {
|
|
319
|
+
await readyPromise
|
|
320
|
+
if (closed) return false
|
|
321
|
+
const message = {
|
|
322
|
+
scope: String(envelope?.scope || ''),
|
|
323
|
+
action: String(envelope?.action || ''),
|
|
324
|
+
payload: envelope?.payload ?? null,
|
|
325
|
+
msgId: randomMsgIdHex(),
|
|
326
|
+
}
|
|
327
|
+
const bytes = encoder.encode(JSON.stringify(message))
|
|
328
|
+
for (const frame of encodeFrames(message.msgId, bytes)) {
|
|
329
|
+
await Promise.resolve(options.sendFrame(message.action, frame))
|
|
330
|
+
sentFrames++
|
|
331
|
+
}
|
|
332
|
+
lastOutboundAt = Date.now()
|
|
333
|
+
return true
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 关闭 pipe 与底层传输。
|
|
338
|
+
* @param {string} [reason='closed'] 关闭原因
|
|
339
|
+
* @returns {Promise<void>}
|
|
340
|
+
*/
|
|
341
|
+
async function close(reason = 'closed') {
|
|
342
|
+
if (closed) return
|
|
343
|
+
closed = true
|
|
344
|
+
closeReason = reason
|
|
345
|
+
clearTimeout(handshakeTimer)
|
|
346
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
|
347
|
+
if (idleTimer) clearInterval(idleTimer)
|
|
348
|
+
try { await Promise.resolve(options.closeTransport?.()) } catch { /* ignore */ }
|
|
349
|
+
if (!ready) rejectReady(new Error(`p2p: link closed before ready (${reason})`))
|
|
350
|
+
emitListeners(downListeners, reason)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
ready: readyPromise,
|
|
355
|
+
/** @returns {string | null} 对端 nodeHash(握手后) */
|
|
356
|
+
get nodeHash() { return remoteNodeHash },
|
|
357
|
+
/** @returns {boolean} 是否发起方 */
|
|
358
|
+
get initiator() { return !!options.initiator },
|
|
359
|
+
/** @returns {string} 提供者 id */
|
|
360
|
+
get providerId() { return providerId },
|
|
361
|
+
/** @returns {number} 提供者 level */
|
|
362
|
+
get level() { return level },
|
|
363
|
+
send,
|
|
364
|
+
/**
|
|
365
|
+
* @param {(envelope: object, remoteNodeHash: string) => void} callback 回调
|
|
366
|
+
* @returns {() => void} 取消订阅
|
|
367
|
+
*/
|
|
368
|
+
onEnvelope(callback) {
|
|
369
|
+
envelopeListeners.add(callback)
|
|
370
|
+
return () => envelopeListeners.delete(callback)
|
|
371
|
+
},
|
|
372
|
+
/**
|
|
373
|
+
* @param {(reason: string) => void} callback 回调
|
|
374
|
+
* @returns {() => void} 取消订阅
|
|
375
|
+
*/
|
|
376
|
+
onDown(callback) {
|
|
377
|
+
downListeners.add(callback)
|
|
378
|
+
return () => downListeners.delete(callback)
|
|
379
|
+
},
|
|
380
|
+
close,
|
|
381
|
+
handleInbound,
|
|
382
|
+
startHandshake,
|
|
383
|
+
maybeSendAuth,
|
|
384
|
+
/**
|
|
385
|
+
* @returns {object} 运行时统计
|
|
386
|
+
*/
|
|
387
|
+
stats() {
|
|
388
|
+
return {
|
|
389
|
+
ready,
|
|
390
|
+
providerId,
|
|
391
|
+
level,
|
|
392
|
+
nodeHash: remoteNodeHash,
|
|
393
|
+
targetNodeHash: targetNodeHash || null,
|
|
394
|
+
initiator: !!options.initiator,
|
|
395
|
+
lastInboundAt,
|
|
396
|
+
lastOutboundAt,
|
|
397
|
+
sentFrames,
|
|
398
|
+
recvFrames,
|
|
399
|
+
closeReason,
|
|
400
|
+
...options.extraStats?.() ?? {},
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
}
|