@xxgxiaoyu/dsh-session-sync 0.0.0-stage → 0.8.3

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.
@@ -0,0 +1,354 @@
1
+ /**
2
+ * LAN discovery over UDP broadcast.
3
+ *
4
+ * Every instance beacons `{t:'dsh-session-sync/hello', ...}` to the broadcast
5
+ * address of each IPv4 interface plus 127.0.0.1 (so two instances on one host
6
+ * find each other during development). A receiver that has not seen an id
7
+ * before answers immediately with a unicast hello, which makes discovery feel
8
+ * instant instead of "within one beacon interval".
9
+ *
10
+ * The beacon carries only identity and a session count; session lists are
11
+ * fetched over the HTTP sync port named in the beacon.
12
+ */
13
+ import dgram from 'node:dgram'
14
+ import os from 'node:os'
15
+ import { parseTarget } from './probe.js'
16
+
17
+ export const MAGIC = 'dsh-session-sync/hello'
18
+ export const PROTOCOL_VERSION = 1
19
+
20
+ const BEACON_INTERVAL_MS = 4000
21
+ /** How often the cross-subnet sweep rebuilds its host list and starts over. */
22
+ const SWEEP_INTERVAL_MS = 60_000
23
+ /** Pacing: 10 unicast hellos every 150 ms keeps the sweep invisible on the wire. */
24
+ const SWEEP_BATCH = 10
25
+ const SWEEP_TICK_MS = 150
26
+ /** Hard cap on expansion so a `/8` cannot turn into 16M packets. */
27
+ const SWEEP_MAX_HOSTS = 4096
28
+
29
+ function ipToInt(ip) {
30
+ return ip.split('.').reduce((acc, part) => (acc << 8 >>> 0) + (Number(part) & 0xff), 0) >>> 0
31
+ }
32
+
33
+ function intToIp(int) {
34
+ return [24, 16, 8, 0].map((shift) => (int >>> shift) & 0xff).join('.')
35
+ }
36
+
37
+ /** Broadcast targets for the interfaces that exist right now. */
38
+ export function broadcastTargets() {
39
+ const out = new Set(['127.0.0.1'])
40
+ let interfaces = {}
41
+ try {
42
+ interfaces = os.networkInterfaces()
43
+ } catch {
44
+ /* ignore */
45
+ }
46
+ for (const entries of Object.values(interfaces)) {
47
+ for (const entry of entries ?? []) {
48
+ if (entry.family !== 'IPv4' || entry.internal) continue
49
+ try {
50
+ const ip = ipToInt(entry.address)
51
+ const mask = ipToInt(entry.netmask)
52
+ out.add(intToIp(((ip & mask) | (~mask >>> 0)) >>> 0))
53
+ } catch {
54
+ /* ignore malformed interface entry */
55
+ }
56
+ out.add('255.255.255.255')
57
+ }
58
+ }
59
+ return [...out]
60
+ }
61
+
62
+ /** Local /24s: broadcast is fine inside one segment, but some switches drop it. */
63
+ function localNets() {
64
+ const out = new Set()
65
+ let interfaces = {}
66
+ try {
67
+ interfaces = os.networkInterfaces()
68
+ } catch {
69
+ return []
70
+ }
71
+ for (const entries of Object.values(interfaces)) {
72
+ for (const entry of entries ?? []) {
73
+ if (entry.family !== 'IPv4' || entry.internal) continue
74
+ const parts = entry.address.split('.')
75
+ if (parts.length === 4) out.add(`${parts[0]}.${parts[1]}.${parts[2]}.0/24`)
76
+ }
77
+ }
78
+ return [...out]
79
+ }
80
+
81
+ /** Expand one CIDR / bare IP into the host addresses it covers. */
82
+ export function expandTarget(spec) {
83
+ const trimmed = String(spec ?? '').trim()
84
+ if (trimmed.length === 0) return []
85
+ const [address, rawPrefix] = trimmed.split('/')
86
+ const parts = address.split('.').map(Number)
87
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return []
88
+ const prefix = rawPrefix === undefined ? 32 : Number(rawPrefix)
89
+ if (!Number.isInteger(prefix) || prefix < 16 || prefix > 32) return []
90
+ const base = ipToInt(address)
91
+ const size = 2 ** (32 - prefix)
92
+ const first = prefix >= 31 ? base : base + 1
93
+ const last = prefix >= 31 ? base + size - 1 : base + size - 2
94
+ const out = []
95
+ for (let value = first; value <= last && out.length < SWEEP_MAX_HOSTS; value += 1) out.push(intToIp(value))
96
+ return out
97
+ }
98
+
99
+ export class Discovery {
100
+ /**
101
+ * @param {object} options
102
+ * @param {number} options.port - UDP port shared by every device.
103
+ * @param {() => object} options.identity - current beacon payload fields.
104
+ * @param {(peer: object, rinfo: {address: string, port: number}) => void} options.onPeer
105
+ * @param {() => string[]} [options.seeds] - extra `host:port` targets.
106
+ * @param {() => string[]} [options.subnets] - CIDRs / IPs to unicast-sweep.
107
+ * @param {(message: string) => void} [options.log]
108
+ */
109
+ constructor({ port, identity, onPeer, seeds, subnets, log }) {
110
+ this.port = port
111
+ this.identity = identity
112
+ this.onPeer = onPeer
113
+ this.seeds = seeds ?? (() => [])
114
+ this.subnets = subnets ?? (() => [])
115
+ this.log = log ?? (() => {})
116
+ this.socket = null
117
+ this.timer = null
118
+ this.sweepTimer = null
119
+ this.sweepRefresh = null
120
+ this._sweep = []
121
+ this.boundPort = null
122
+ this._seen = new Map()
123
+ }
124
+
125
+ async start() {
126
+ if (this.socket !== null) return
127
+ const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
128
+ this.socket = socket
129
+ socket.on('error', (error) => {
130
+ this.log(`发现服务 UDP 错误: ${error?.message ?? error}`)
131
+ if (this.boundPort === null) {
132
+ // Port taken by an instance that does not share it: retry on an
133
+ // ephemeral port. Peers still answer to the source port of our beacons.
134
+ try {
135
+ socket.close()
136
+ } catch {
137
+ /* ignore */
138
+ }
139
+ this.socket = null
140
+ this._listen(0).catch((retry) => this.log(`发现服务启动失败: ${retry?.message ?? retry}`))
141
+ }
142
+ })
143
+ socket.on('message', (buffer, rinfo) => this._onMessage(buffer, rinfo))
144
+ await this._listen(this.port)
145
+ // A configured port of 0 means "pick any free port"; beacons must then go
146
+ // to the port the OS actually assigned, not to 0.
147
+ if (this.port === 0 && this.boundPort !== null) this.port = this.boundPort
148
+ this.timer = setInterval(() => this.beacon(), BEACON_INTERVAL_MS)
149
+ this.timer.unref?.()
150
+ this.beacon()
151
+ this.startSweep()
152
+ }
153
+
154
+ _listen(port) {
155
+ return new Promise((resolve, reject) => {
156
+ const socket = this.socket
157
+ if (socket === null) {
158
+ reject(new Error('socket closed'))
159
+ return
160
+ }
161
+ const onListening = () => {
162
+ socket.off('error', onError)
163
+ try {
164
+ socket.setBroadcast(true)
165
+ socket.setMulticastTTL?.(1)
166
+ } catch {
167
+ /* optional socket tuning */
168
+ }
169
+ this.boundPort = socket.address().port
170
+ this.log(`发现服务已监听 UDP ${this.boundPort}`)
171
+ resolve()
172
+ }
173
+ const onError = (error) => {
174
+ socket.off('listening', onListening)
175
+ try {
176
+ socket.close()
177
+ } catch {
178
+ /* ignore */
179
+ }
180
+ this.socket = null
181
+ reject(error)
182
+ }
183
+ socket.once('listening', onListening)
184
+ socket.once('error', onError)
185
+ socket.bind(port, '0.0.0.0')
186
+ })
187
+ }
188
+
189
+ beacon() {
190
+ const socket = this.socket
191
+ if (socket === null || this.boundPort === null) return
192
+ const payload = Buffer.from(JSON.stringify({
193
+ t: MAGIC,
194
+ proto: PROTOCOL_VERSION,
195
+ ...this.identity(),
196
+ at: Date.now(),
197
+ }))
198
+ const targets = new Set(broadcastTargets())
199
+ for (const seed of this.seeds()) {
200
+ const host = String(seed).split(':')[0]
201
+ if (host.length > 0) targets.add(host)
202
+ }
203
+ for (const target of targets) {
204
+ socket.send(payload, 0, payload.length, this.port, target, (error) => {
205
+ if (error !== undefined && error !== null && target !== '127.0.0.1') {
206
+ // Interfaces come and go; a failed broadcast is routine.
207
+ }
208
+ })
209
+ }
210
+ for (const seed of this.seeds()) {
211
+ const [host, rawPort] = String(seed).split(':')
212
+ const port = Number(rawPort)
213
+ if (host && Number.isInteger(port) && port > 0) {
214
+ socket.send(payload, 0, payload.length, port, host, () => {})
215
+ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Silent cross-subnet scan: a hello is a normal discovery packet, so a peer
221
+ * that receives one answers with its own identity and shows up in the list
222
+ * exactly like a broadcast peer. No port scanning, no HTTP probe.
223
+ */
224
+ startSweep() {
225
+ if (this.sweepTimer !== null) return
226
+ const refill = () => {
227
+ const targets = new Map()
228
+ for (const spec of [...localNets(), ...this.subnets()]) {
229
+ const parsed = parseTarget(spec)
230
+ // `10.0.0.7:47901` is allowed so a peer whose UDP port moved is still
231
+ // reachable; a bare CIDR/IP sweeps the shared discovery port.
232
+ const hosts = parsed !== null && parsed.port !== null ? [parsed.host] : expandTarget(spec)
233
+ for (const host of hosts) {
234
+ if (!targets.has(host)) targets.set(host, parsed?.port ?? this.port)
235
+ }
236
+ }
237
+ this._sweep = [...targets.entries()].map(([host, port]) => ({ host, port }))
238
+ this._sweepIndex = 0
239
+ }
240
+ refill()
241
+ this.sweepRefresh = setInterval(refill, SWEEP_INTERVAL_MS)
242
+ this.sweepRefresh.unref?.()
243
+ this.sweepTimer = setInterval(() => this._sendSweepBatch(), SWEEP_TICK_MS)
244
+ this.sweepTimer.unref?.()
245
+ }
246
+
247
+ _sendSweepBatch() {
248
+ const socket = this.socket
249
+ if (socket === null || this.boundPort === null) return
250
+ if (!Array.isArray(this._sweep) || this._sweep.length === 0) return
251
+ const identity = this.identity()
252
+ const payload = Buffer.from(JSON.stringify({
253
+ t: MAGIC,
254
+ proto: PROTOCOL_VERSION,
255
+ ...identity,
256
+ at: Date.now(),
257
+ }))
258
+ const selfHost = typeof identity.host === 'string' ? identity.host : null
259
+ let sent = 0
260
+ while (sent < SWEEP_BATCH && this._sweepIndex < this._sweep.length) {
261
+ const entry = this._sweep[this._sweepIndex]
262
+ this._sweepIndex += 1
263
+ const host = entry.host
264
+ if (host === selfHost || host === '0.0.0.0' || host.endsWith('.255')) continue
265
+ socket.send(payload, 0, payload.length, entry.port, host, () => {})
266
+ sent += 1
267
+ }
268
+ // Wrap around so a short list keeps getting poked between refills.
269
+ if (this._sweepIndex >= this._sweep.length) this._sweepIndex = 0
270
+ }
271
+
272
+ _onMessage(buffer, rinfo) {
273
+ let message
274
+ try {
275
+ message = JSON.parse(buffer.toString('utf8'))
276
+ } catch {
277
+ return
278
+ }
279
+ if (message?.t !== MAGIC || message?.proto !== PROTOCOL_VERSION) return
280
+ if (typeof message.id !== 'string' || message.id.length === 0) return
281
+ const self = this.identity()
282
+ if (message.id === self.id) return
283
+ const peer = {
284
+ id: message.id,
285
+ name: typeof message.name === 'string' ? message.name : '',
286
+ host: typeof message.host === 'string' ? message.host : rinfo.address,
287
+ port: Number.isInteger(message.port) ? message.port : null,
288
+ platform: typeof message.platform === 'string' ? message.platform : '',
289
+ version: typeof message.version === 'string' ? message.version : '',
290
+ sessions: Number.isInteger(message.sessions) ? message.sessions : null,
291
+ at: typeof message.at === 'number' ? message.at : Date.now(),
292
+ address: rinfo.address,
293
+ }
294
+ const known = this._seen.has(peer.id)
295
+ this._seen.set(peer.id, Date.now())
296
+ this.onPeer(peer, rinfo)
297
+ if (!known) {
298
+ const socket = this.socket
299
+ if (socket !== null && this.boundPort !== null) {
300
+ const reply = Buffer.from(JSON.stringify({
301
+ t: MAGIC,
302
+ proto: PROTOCOL_VERSION,
303
+ ...self,
304
+ at: Date.now(),
305
+ }))
306
+ socket.send(reply, 0, reply.length, rinfo.port, rinfo.address, () => {})
307
+ }
308
+ }
309
+ }
310
+
311
+ /** Rebuild the sweep list right now (used after the settings change). */
312
+ refresh() {
313
+ if (this.sweepTimer === null) {
314
+ this.startSweep()
315
+ return
316
+ }
317
+ const targets = new Map()
318
+ for (const spec of [...localNets(), ...this.subnets()]) {
319
+ const parsed = parseTarget(spec)
320
+ const hosts = parsed !== null && parsed.port !== null ? [parsed.host] : expandTarget(spec)
321
+ for (const host of hosts) {
322
+ if (!targets.has(host)) targets.set(host, parsed?.port ?? this.port)
323
+ }
324
+ }
325
+ this._sweep = [...targets.entries()].map(([host, port]) => ({ host, port }))
326
+ this._sweepIndex = 0
327
+ }
328
+
329
+ close() {
330
+ if (this.timer !== null) {
331
+ clearInterval(this.timer)
332
+ this.timer = null
333
+ }
334
+ if (this.sweepTimer !== null) {
335
+ clearInterval(this.sweepTimer)
336
+ this.sweepTimer = null
337
+ }
338
+ if (this.sweepRefresh !== null) {
339
+ clearInterval(this.sweepRefresh)
340
+ this.sweepRefresh = null
341
+ }
342
+ this._sweep = []
343
+ const socket = this.socket
344
+ this.socket = null
345
+ this.boundPort = null
346
+ if (socket !== null) {
347
+ try {
348
+ socket.close()
349
+ } catch {
350
+ /* ignore */
351
+ }
352
+ }
353
+ }
354
+ }