comq 0.25.1 → 0.26.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/docs/headers.md +16 -1
- package/features/properties.feature +39 -0
- package/features/shards.virtual.feature +89 -0
- package/features/steps/context.js +29 -2
- package/features/steps/hooks.js +1 -0
- package/features/steps/names.js +245 -0
- package/features/steps/networks.js +23 -5
- package/features/steps/rpc.js +14 -2
- package/package.json +1 -1
- package/readme.md +102 -4
- package/source/channel.js +147 -2
- package/source/connect.js +18 -15
- package/source/connection.js +8 -5
- package/source/events.js +7 -0
- package/source/io.js +31 -9
- package/source/shards/channel.js +289 -19
- package/source/shards/connection.js +404 -18
- package/source/shards/expand.js +57 -0
- package/source/shards/index.js +2 -0
- package/source/shards/resolve.js +134 -0
- package/source/shards/watch.js +257 -0
- package/source/singleton.js +3 -2
- package/test/amqplib.mock.js +1 -0
- package/test/assert.test.js +7 -2
- package/test/channel.quiet.test.js +301 -0
- package/test/connect.test.js +49 -8
- package/test/connection.mock.js +3 -0
- package/test/connection.test.js +15 -0
- package/test/io.call.test.js +1 -1
- package/test/io.diagnostics.test.js +1 -1
- package/test/io.reply.test.js +12 -1
- package/test/io.request.test.js +18 -0
- package/test/io.shards.test.js +85 -0
- package/test/shards/channel.test.js +411 -0
- package/test/shards/connection.test.js +372 -17
- package/test/shards/expand.test.js +58 -0
- package/test/shards/resolve.test.js +73 -0
- package/test/shards/watch.test.js +264 -0
- package/test/singleton.test.js +1 -1
- package/types/channel.d.ts +23 -0
- package/types/connection.d.ts +13 -2
- package/types/diagnostic.d.ts +2 -1
- package/types/io.d.ts +17 -1
- package/types/shards.d.ts +58 -0
|
@@ -3,43 +3,127 @@
|
|
|
3
3
|
const events = require('../events')
|
|
4
4
|
const channel = require('./channel')
|
|
5
5
|
const emitter = require('../emitter')
|
|
6
|
+
const { Watch } = require('./watch')
|
|
7
|
+
const { hostOf, locate } = require('./resolve')
|
|
6
8
|
|
|
7
9
|
/**
|
|
10
|
+
* The URLs it is given are virtual shards: names, of which several may stand for one broker.
|
|
11
|
+
* A connection is made to each broker rather than to each name, and the brokers are followed
|
|
12
|
+
* as the names move from one to another.
|
|
13
|
+
*
|
|
14
|
+
* A broker is joined before another is retired, and a retired one is left only once it has
|
|
15
|
+
* nothing more to give: see `#reconcile` and `#idle`.
|
|
16
|
+
*
|
|
8
17
|
* @implements {comq.Connection}
|
|
9
18
|
*/
|
|
10
19
|
class Connection {
|
|
11
|
-
/** @type {
|
|
12
|
-
#
|
|
20
|
+
/** @type {string[]} */
|
|
21
|
+
#urls
|
|
22
|
+
|
|
23
|
+
/** @type {(url: string, servername?: string) => comq.Connection} */
|
|
24
|
+
#create
|
|
25
|
+
|
|
26
|
+
/** @type {Watch} */
|
|
27
|
+
#watch
|
|
28
|
+
|
|
29
|
+
/** @type {Map<string, string>} the address of each name */
|
|
30
|
+
#addresses = new Map()
|
|
31
|
+
|
|
32
|
+
/** @type {Map<string, comq.shards.Location>} the brokers the names stand for, by URL */
|
|
33
|
+
#wanted = new Map()
|
|
34
|
+
|
|
35
|
+
/** @type {Map<number, comq.shards.Shard>} */
|
|
36
|
+
#shards = new Map()
|
|
37
|
+
|
|
38
|
+
/** @type {number} the number the next shard gets: one is never given twice */
|
|
39
|
+
#next = 0
|
|
40
|
+
|
|
41
|
+
/** @type {Set<comq.Channel>} */
|
|
42
|
+
#channels = new Set()
|
|
43
|
+
|
|
44
|
+
/** @type {ReturnType<setTimeout> | undefined} */
|
|
45
|
+
#inspection
|
|
46
|
+
|
|
47
|
+
#opened = false
|
|
48
|
+
#closed = false
|
|
13
49
|
|
|
14
50
|
#diagnostics = emitter.create()
|
|
15
51
|
|
|
16
52
|
/**
|
|
17
|
-
* @param {
|
|
53
|
+
* @param {string[]} urls
|
|
54
|
+
* @param {(url: string, servername?: string) => comq.Connection} create
|
|
55
|
+
* @param {comq.shards.Timing} [timing]
|
|
18
56
|
*/
|
|
19
|
-
constructor (
|
|
20
|
-
this.#
|
|
57
|
+
constructor (urls, create, timing) {
|
|
58
|
+
this.#urls = urls
|
|
59
|
+
this.#create = create
|
|
60
|
+
|
|
61
|
+
const hosts = urls.map(hostOf).filter((host) => host !== undefined)
|
|
21
62
|
|
|
22
|
-
|
|
63
|
+
this.#watch = new Watch(hosts, timing, this.#urgent)
|
|
64
|
+
this.#watch.on('change', (addresses) => this.#follow(addresses))
|
|
65
|
+
this.#watch.on('error', (exception) => this.#diagnostics.emit('error', exception))
|
|
23
66
|
}
|
|
24
67
|
|
|
25
68
|
async open () {
|
|
26
|
-
|
|
69
|
+
this.#closed = false
|
|
70
|
+
|
|
71
|
+
const addresses = await this.#watch.start()
|
|
72
|
+
|
|
73
|
+
if (this.#closed) return
|
|
74
|
+
|
|
75
|
+
this.#addresses = addresses
|
|
76
|
+
this.#wanted = this.#locations(addresses)
|
|
77
|
+
|
|
78
|
+
const shards = Array.from(this.#wanted.values(), (location) => this.#shard(location))
|
|
79
|
+
const openings = shards.map((shard) => shard.connection.open())
|
|
27
80
|
|
|
28
81
|
try {
|
|
29
82
|
await Promise.all(openings)
|
|
30
83
|
} catch (exception) {
|
|
31
|
-
await this.#cancel(openings, exception)
|
|
84
|
+
await this.#cancel(openings, shards, exception)
|
|
32
85
|
}
|
|
86
|
+
|
|
87
|
+
for (const shard of shards) shard.state = ACTIVE
|
|
88
|
+
|
|
89
|
+
this.#opened = true
|
|
90
|
+
|
|
91
|
+
// the names may have moved while the connections were being made
|
|
92
|
+
this.#reconcile()
|
|
33
93
|
}
|
|
34
94
|
|
|
35
95
|
async close () {
|
|
36
|
-
|
|
96
|
+
this.#closed = true
|
|
97
|
+
this.#opened = false
|
|
98
|
+
this.#watch.stop()
|
|
99
|
+
|
|
100
|
+
clearTimeout(this.#inspection)
|
|
101
|
+
|
|
102
|
+
const closing = Array.from(this.#shards.values(), (shard) => shard.connection.close())
|
|
37
103
|
|
|
38
104
|
await Promise.all(closing)
|
|
39
105
|
}
|
|
40
106
|
|
|
41
107
|
async createChannel (type) {
|
|
42
|
-
|
|
108
|
+
const members = this.#members()
|
|
109
|
+
const created = await channel.create(members, type)
|
|
110
|
+
|
|
111
|
+
this.#channels.add(created)
|
|
112
|
+
|
|
113
|
+
// what left, joined or retired while the channel was being made
|
|
114
|
+
for (const index of members.keys()) {
|
|
115
|
+
if (this.#member(this.#shards.get(index)) === false) await created.leave(index)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const shard of this.#shards.values()) {
|
|
119
|
+
if (!this.#member(shard)) continue
|
|
120
|
+
|
|
121
|
+
created.join(shard.connection, shard.index).catch(noop)
|
|
122
|
+
|
|
123
|
+
if (shard.state === RETIRING) created.retire(shard.index)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return created
|
|
43
127
|
}
|
|
44
128
|
|
|
45
129
|
async diagnose (event, listener) {
|
|
@@ -51,29 +135,331 @@ class Connection {
|
|
|
51
135
|
}
|
|
52
136
|
|
|
53
137
|
/**
|
|
54
|
-
* @
|
|
55
|
-
|
|
138
|
+
* @return {Map<number, comq.Connection>} the connections a channel is made over
|
|
139
|
+
*/
|
|
140
|
+
#members () {
|
|
141
|
+
const members = new Map()
|
|
142
|
+
|
|
143
|
+
for (const shard of this.#shards.values()) {
|
|
144
|
+
if (this.#member(shard)) members.set(shard.index, shard.connection)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return members
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {comq.shards.Shard} [shard]
|
|
152
|
+
* @return {boolean} whether channels are made over a shard
|
|
153
|
+
*/
|
|
154
|
+
#member (shard) {
|
|
155
|
+
return shard !== undefined && shard.joined && shard.state !== LEAVING
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {Map<string, string>} addresses the address of each name
|
|
160
|
+
* @return {Map<string, comq.shards.Location>}
|
|
56
161
|
*/
|
|
57
|
-
#
|
|
162
|
+
#locations (addresses) {
|
|
163
|
+
const locations = new Map()
|
|
164
|
+
|
|
165
|
+
for (const url of this.#urls) {
|
|
166
|
+
const location = locate(url, addresses.get(hostOf(url)))
|
|
167
|
+
|
|
168
|
+
// the first of the names of a broker is the one it is verified by
|
|
169
|
+
if (!locations.has(location.url)) locations.set(location.url, location)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return locations
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {comq.shards.Location} location
|
|
177
|
+
* @return {comq.shards.Shard}
|
|
178
|
+
*/
|
|
179
|
+
#shard (location) {
|
|
180
|
+
const index = this.#next++
|
|
181
|
+
const connection = this.#create(location.url, location.servername)
|
|
182
|
+
|
|
183
|
+
/** @type {comq.shards.Shard} */
|
|
184
|
+
const shard = { ...location, index, connection, state: JOINING, joined: true, since: 0, listeners: [] }
|
|
185
|
+
|
|
58
186
|
for (const event of events.connection) {
|
|
59
|
-
|
|
187
|
+
const listener = (...args) => this.#diagnostics.emit(event, ...args, index)
|
|
188
|
+
|
|
189
|
+
connection.diagnose(event, listener)
|
|
190
|
+
shard.listeners.push([event, listener])
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// a connection that is lost may be so because its broker has moved
|
|
194
|
+
const lost = (error) => { if (error !== undefined) this.#watch.poke() }
|
|
195
|
+
|
|
196
|
+
connection.diagnose('close', lost)
|
|
197
|
+
shard.listeners.push(['close', lost])
|
|
198
|
+
|
|
199
|
+
this.#shards.set(index, shard)
|
|
200
|
+
|
|
201
|
+
return shard
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {comq.shards.Shard} shard
|
|
206
|
+
*/
|
|
207
|
+
#dismiss (shard) {
|
|
208
|
+
for (const [event, listener] of shard.listeners) shard.connection.forget(event, listener)
|
|
209
|
+
|
|
210
|
+
this.#shards.delete(shard.index)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string} url
|
|
215
|
+
* @return {comq.shards.Shard | undefined} the shard of a broker, be it on its way out
|
|
216
|
+
*/
|
|
217
|
+
#shardOf (url) {
|
|
218
|
+
for (const shard of this.#shards.values()) if (shard.url === url) return shard
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A name whose broker is out of reach has nothing to wait for: there is nothing to be had
|
|
223
|
+
* from that broker meanwhile, and a broker that has come back under another address is
|
|
224
|
+
* found at once, as it is when a connection is restored by name.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} host
|
|
227
|
+
* @return {boolean}
|
|
228
|
+
*/
|
|
229
|
+
#urgent = (host) => {
|
|
230
|
+
const address = this.#addresses.get(host)
|
|
231
|
+
|
|
232
|
+
for (const url of this.#urls) {
|
|
233
|
+
if (hostOf(url) !== host) continue
|
|
234
|
+
|
|
235
|
+
const shard = this.#shardOf(locate(url, address).url)
|
|
236
|
+
|
|
237
|
+
if (shard?.connection.connected === false) return true
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {Map<string, string>} addresses
|
|
245
|
+
*/
|
|
246
|
+
#follow (addresses) {
|
|
247
|
+
this.#addresses = addresses
|
|
248
|
+
this.#wanted = this.#locations(addresses)
|
|
249
|
+
this.#reconcile()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Brings the shards in line with the brokers the names stand for, making before breaking:
|
|
254
|
+
* nothing is retired until every broker that is named has joined, so a name that points at
|
|
255
|
+
* a broker that is not there yet takes nothing away.
|
|
256
|
+
*/
|
|
257
|
+
#reconcile () {
|
|
258
|
+
if (!this.#opened || this.#closed) return
|
|
259
|
+
|
|
260
|
+
for (const [url, location] of this.#wanted) {
|
|
261
|
+
// one that is being left is joined anew once it has been
|
|
262
|
+
if (this.#shardOf(url) === undefined) this.#join(location).catch(noop)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let made = true
|
|
266
|
+
|
|
267
|
+
for (const shard of this.#shards.values()) {
|
|
268
|
+
const named = this.#wanted.has(shard.url)
|
|
269
|
+
|
|
270
|
+
if (named && shard.state === RETIRING) this.#restore(shard)
|
|
271
|
+
if (!named && shard.state === JOINING) this.#leave(shard).catch(noop)
|
|
272
|
+
if (named && shard.state !== ACTIVE) made = false
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!made) return
|
|
276
|
+
|
|
277
|
+
for (const shard of this.#shards.values()) {
|
|
278
|
+
if (!this.#wanted.has(shard.url) && shard.state === ACTIVE) this.#retire(shard)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {comq.shards.Location} location
|
|
284
|
+
* @return {Promise<void>}
|
|
285
|
+
*/
|
|
286
|
+
async #join (location) {
|
|
287
|
+
const shard = this.#shard(location)
|
|
288
|
+
|
|
289
|
+
shard.joined = false
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
await shard.connection.open()
|
|
293
|
+
} catch (exception) {
|
|
294
|
+
// a broker that refuses is asked again, as its name keeps pointing at it
|
|
295
|
+
this.#diagnostics.emit('error', exception, shard.index)
|
|
296
|
+
this.#dismiss(shard)
|
|
297
|
+
|
|
298
|
+
return this.#later(() => this.#reconcile())
|
|
60
299
|
}
|
|
300
|
+
|
|
301
|
+
if (shard.state !== JOINING) return
|
|
302
|
+
|
|
303
|
+
// a channel made from now on takes the shard in by itself
|
|
304
|
+
shard.joined = true
|
|
305
|
+
|
|
306
|
+
const joining = Array.from(this.#channels,
|
|
307
|
+
(channel) => channel.join(shard.connection, shard.index))
|
|
308
|
+
|
|
309
|
+
const results = await Promise.allSettled(joining)
|
|
310
|
+
const failures = results.filter((result) => result.status === 'rejected')
|
|
311
|
+
|
|
312
|
+
for (const failure of failures) this.#diagnostics.emit('error', failure.reason, shard.index)
|
|
313
|
+
|
|
314
|
+
if (shard.state !== JOINING) return
|
|
315
|
+
|
|
316
|
+
// a broker that cannot be consumed from has not joined: taken for one that has, it would
|
|
317
|
+
// let the broker that is consumed from be retired
|
|
318
|
+
if (failures.length > 0) return await this.#leave(shard, true)
|
|
319
|
+
|
|
320
|
+
shard.state = ACTIVE
|
|
321
|
+
|
|
322
|
+
this.#diagnostics.emit(JOIN, shard.index, shard.address)
|
|
323
|
+
this.#reconcile()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @param {comq.shards.Shard} shard
|
|
328
|
+
*/
|
|
329
|
+
#retire (shard) {
|
|
330
|
+
shard.state = RETIRING
|
|
331
|
+
shard.since = Date.now()
|
|
332
|
+
shard.down = undefined
|
|
333
|
+
|
|
334
|
+
for (const channel of this.#channels) channel.retire(shard.index)
|
|
335
|
+
|
|
336
|
+
this.#diagnostics.emit(RETIRE, shard.index, shard.address)
|
|
337
|
+
this.#inspect()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @param {comq.shards.Shard} shard
|
|
342
|
+
*/
|
|
343
|
+
#restore (shard) {
|
|
344
|
+
shard.state = ACTIVE
|
|
345
|
+
|
|
346
|
+
for (const channel of this.#channels) channel.restore(shard.index)
|
|
347
|
+
|
|
348
|
+
this.#diagnostics.emit(JOIN, shard.index, shard.address)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* @param {comq.shards.Shard} shard
|
|
353
|
+
* @param {boolean} [failed] whether it is let go for having failed to join, which is tried
|
|
354
|
+
* again later rather than at once
|
|
355
|
+
* @return {Promise<void>}
|
|
356
|
+
*/
|
|
357
|
+
async #leave (shard, failed = false) {
|
|
358
|
+
const retired = shard.state === RETIRING
|
|
359
|
+
|
|
360
|
+
shard.state = LEAVING
|
|
361
|
+
|
|
362
|
+
const leaving = Array.from(this.#channels, (channel) => channel.leave(shard.index))
|
|
363
|
+
|
|
364
|
+
await Promise.allSettled(leaving)
|
|
365
|
+
await shard.connection.close()
|
|
366
|
+
|
|
367
|
+
this.#dismiss(shard)
|
|
368
|
+
|
|
369
|
+
// one that never joined is not said to have left
|
|
370
|
+
if (retired) this.#diagnostics.emit(LEAVE, shard.index, shard.address)
|
|
371
|
+
|
|
372
|
+
if (failed) this.#later(() => this.#reconcile())
|
|
373
|
+
else this.#reconcile()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Looks at the retiring shards for as long as there are any.
|
|
378
|
+
*/
|
|
379
|
+
#inspect () {
|
|
380
|
+
clearTimeout(this.#inspection)
|
|
381
|
+
|
|
382
|
+
this.#inspection = this.#later(async () => {
|
|
383
|
+
for (const shard of Array.from(this.#shards.values())) {
|
|
384
|
+
if (shard.state !== RETIRING) continue
|
|
385
|
+
|
|
386
|
+
const idle = await this.#idle(shard)
|
|
387
|
+
|
|
388
|
+
// it may have been named again meanwhile
|
|
389
|
+
if (idle && shard.state === RETIRING && !this.#closed) this.#leave(shard).catch(noop)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for (const shard of this.#shards.values()) if (shard.state === RETIRING) return this.#inspect()
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* A retiring shard is left once it has had nothing to do for the linger time, counted from
|
|
398
|
+
* the moment it was retired: what is published to its broker by those who have not seen the
|
|
399
|
+
* names move yet is consumed meanwhile. One that is out of reach for that long is given up on.
|
|
400
|
+
*
|
|
401
|
+
* @param {comq.shards.Shard} shard
|
|
402
|
+
* @return {Promise<boolean>}
|
|
403
|
+
*/
|
|
404
|
+
async #idle (shard) {
|
|
405
|
+
const now = Date.now()
|
|
406
|
+
const linger = this.#watch.linger
|
|
407
|
+
|
|
408
|
+
if (shard.connection.connected === false) {
|
|
409
|
+
shard.down ??= now
|
|
410
|
+
|
|
411
|
+
return now - shard.down >= linger
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
shard.down = undefined
|
|
415
|
+
|
|
416
|
+
const asked = Array.from(this.#channels, (channel) => channel.quiet(shard.index))
|
|
417
|
+
const quiet = await Promise.all(asked)
|
|
418
|
+
|
|
419
|
+
return now - Math.max(shard.since, ...quiet) >= linger
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @param {() => any} fn
|
|
424
|
+
* @return {ReturnType<setTimeout>}
|
|
425
|
+
*/
|
|
426
|
+
#later (fn) {
|
|
427
|
+
const timer = setTimeout(() => Promise.resolve().then(fn).catch(noop), this.#watch.interval)
|
|
428
|
+
|
|
429
|
+
timer.unref()
|
|
430
|
+
|
|
431
|
+
return timer
|
|
61
432
|
}
|
|
62
433
|
|
|
63
434
|
/**
|
|
64
435
|
* @param {Promise<any>[]} openings
|
|
436
|
+
* @param {comq.shards.Shard[]} shards
|
|
65
437
|
* @param {Error} exception
|
|
66
438
|
*/
|
|
67
|
-
async #cancel (openings, exception) {
|
|
439
|
+
async #cancel (openings, shards, exception) {
|
|
440
|
+
this.#watch.stop()
|
|
441
|
+
|
|
68
442
|
const settled = await Promise.allSettled(openings)
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const closings = opened.map((index) => this.#connections[index].close())
|
|
443
|
+
const opened = shards.filter((_, index) => settled[index].status === 'fulfilled')
|
|
444
|
+
const closings = opened.map((shard) => shard.connection.close())
|
|
72
445
|
|
|
73
446
|
await Promise.all(closings)
|
|
74
447
|
|
|
448
|
+
for (const shard of shards) this.#dismiss(shard)
|
|
449
|
+
|
|
75
450
|
throw exception
|
|
76
451
|
}
|
|
77
452
|
}
|
|
78
453
|
|
|
454
|
+
const JOINING = 'joining'
|
|
455
|
+
const ACTIVE = 'active'
|
|
456
|
+
const RETIRING = 'retiring'
|
|
457
|
+
const LEAVING = 'leaving'
|
|
458
|
+
|
|
459
|
+
const JOIN = 'join'
|
|
460
|
+
const RETIRE = 'retire'
|
|
461
|
+
const LEAVE = 'leave'
|
|
462
|
+
|
|
463
|
+
function noop () {}
|
|
464
|
+
|
|
79
465
|
exports.Connection = Connection
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Expands the ranges in the host of a URL into a URL per number: `amqp://rmq[0..3].example.com`
|
|
5
|
+
* is `rmq0`, `rmq1` and `rmq2`. The end is exclusive, so a range starting at zero names as many
|
|
6
|
+
* hosts as its end says. A start written with leading zeros pads every number to its width.
|
|
7
|
+
*
|
|
8
|
+
* Only the host is looked at: credentials, a path and a query are free to contain anything, and
|
|
9
|
+
* an IPv6 literal, which is bracketed as well, has no `..` in it.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} url
|
|
12
|
+
* @return {string[]}
|
|
13
|
+
*/
|
|
14
|
+
function expand (url) {
|
|
15
|
+
const parts = url.match(AUTHORITY)
|
|
16
|
+
|
|
17
|
+
if (parts === null) return [url]
|
|
18
|
+
|
|
19
|
+
const [, head, host, tail] = parts
|
|
20
|
+
|
|
21
|
+
return hosts(host).map((host) => head + host + tail)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} host
|
|
26
|
+
* @return {string[]}
|
|
27
|
+
*/
|
|
28
|
+
function hosts (host) {
|
|
29
|
+
const range = host.match(RANGE)
|
|
30
|
+
|
|
31
|
+
if (range === null) return [host]
|
|
32
|
+
|
|
33
|
+
const [expression, from, to] = range
|
|
34
|
+
const start = Number(from)
|
|
35
|
+
const end = Number(to)
|
|
36
|
+
|
|
37
|
+
if (end <= start) throw new RangeError(`Range ${expression} names no hosts: its end is exclusive`)
|
|
38
|
+
|
|
39
|
+
const width = from.length > 1 && from.startsWith('0') ? from.length : 0
|
|
40
|
+
const before = host.slice(0, range.index)
|
|
41
|
+
const after = host.slice(range.index + expression.length)
|
|
42
|
+
const expanded = []
|
|
43
|
+
|
|
44
|
+
for (let n = start; n < end; n++) {
|
|
45
|
+
// whatever follows may hold a range of its own
|
|
46
|
+
for (const rest of hosts(after)) expanded.push(before + String(n).padStart(width, '0') + rest)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return expanded
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What precedes the host, the host with its port, and what follows. */
|
|
53
|
+
const AUTHORITY = /^([a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?)([^/?#]*)(.*)$/i
|
|
54
|
+
|
|
55
|
+
const RANGE = /\[(\d+)\.\.(\d+)]/
|
|
56
|
+
|
|
57
|
+
exports.expand = expand
|
package/source/shards/index.js
CHANGED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const net = require('node:net')
|
|
4
|
+
const dns = require('node:dns').promises
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The addresses a name stands for, and for how long the answer may be cached, in seconds.
|
|
8
|
+
*
|
|
9
|
+
* DNS is asked directly, since nothing else tells the TTL. A name it does not know is looked up
|
|
10
|
+
* the way a socket would have, which is how a name from the hosts file or one completed by the
|
|
11
|
+
* search list is found; such an answer carries no TTL.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} host
|
|
14
|
+
* @return {Promise<comq.shards.Resolution>}
|
|
15
|
+
*/
|
|
16
|
+
async function resolve (host) {
|
|
17
|
+
if (global.COMQ_TESTING_LOOKUP !== undefined) return await global.COMQ_TESTING_LOOKUP(host)
|
|
18
|
+
|
|
19
|
+
const answers = await Promise.allSettled([
|
|
20
|
+
dns.resolve4(host, { ttl: true }),
|
|
21
|
+
dns.resolve6(host, { ttl: true })
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
const records = answers.flatMap((answer) => answer.status === 'fulfilled' ? answer.value : [])
|
|
25
|
+
|
|
26
|
+
if (records.length > 0) {
|
|
27
|
+
return {
|
|
28
|
+
addresses: records.map((record) => record.address),
|
|
29
|
+
ttl: Math.max(...records.map((record) => record.ttl))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const found = await dns.lookup(host, { all: true })
|
|
34
|
+
|
|
35
|
+
return { addresses: found.map((entry) => entry.address) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The one address a name is connected through. A name is one broker, and several addresses are
|
|
40
|
+
* as many ways to it: the one in use is kept for as long as it is listed, and the choice is
|
|
41
|
+
* otherwise the same whichever order they came in, so that the names of one broker agree on it.
|
|
42
|
+
*
|
|
43
|
+
* @param {string[]} addresses
|
|
44
|
+
* @param {string} [current]
|
|
45
|
+
* @return {string}
|
|
46
|
+
*/
|
|
47
|
+
function choose (addresses, current) {
|
|
48
|
+
if (current !== undefined && addresses.includes(current)) return current
|
|
49
|
+
|
|
50
|
+
return addresses.toSorted(order)[0]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* IPv4 first, which is where a broker is found whenever it has both.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} one
|
|
57
|
+
* @param {string} another
|
|
58
|
+
* @return {number}
|
|
59
|
+
*/
|
|
60
|
+
function order (one, another) {
|
|
61
|
+
return net.isIP(one) - net.isIP(another) || (one < another ? -1 : Number(one > another))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What a URL names: the host to resolve, or nothing when it is an address already or is not
|
|
66
|
+
* something that can be told.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} url
|
|
69
|
+
* @return {string | undefined}
|
|
70
|
+
*/
|
|
71
|
+
function hostOf (url) {
|
|
72
|
+
const parsed = parse(url)
|
|
73
|
+
|
|
74
|
+
if (parsed === undefined) return undefined
|
|
75
|
+
|
|
76
|
+
const host = unbracketed(parsed.hostname)
|
|
77
|
+
|
|
78
|
+
return host === '' || net.isIP(host) !== 0 ? undefined : host
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The broker a URL stands for once its name is resolved: the URL of the address, which is what
|
|
83
|
+
* tells one broker from another, and the name TLS verifies it by.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} url
|
|
86
|
+
* @param {string} [address]
|
|
87
|
+
* @return {comq.shards.Location}
|
|
88
|
+
*/
|
|
89
|
+
function locate (url, address) {
|
|
90
|
+
const host = hostOf(url)
|
|
91
|
+
|
|
92
|
+
if (host === undefined || address === undefined) return { url, address: addressOf(url) }
|
|
93
|
+
|
|
94
|
+
const parsed = parse(url)
|
|
95
|
+
|
|
96
|
+
parsed.hostname = net.isIPv6(address) ? '[' + address + ']' : address
|
|
97
|
+
|
|
98
|
+
return { url: parsed.href, address, servername: host }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} url
|
|
103
|
+
* @return {string}
|
|
104
|
+
*/
|
|
105
|
+
function addressOf (url) {
|
|
106
|
+
const parsed = parse(url)
|
|
107
|
+
|
|
108
|
+
return parsed === undefined ? url : unbracketed(parsed.hostname)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} url
|
|
113
|
+
* @return {URL | undefined}
|
|
114
|
+
*/
|
|
115
|
+
function parse (url) {
|
|
116
|
+
try {
|
|
117
|
+
return new URL(url)
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} hostname
|
|
125
|
+
* @return {string}
|
|
126
|
+
*/
|
|
127
|
+
function unbracketed (hostname) {
|
|
128
|
+
return hostname.replace(/^\[|]$/g, '')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
exports.resolve = resolve
|
|
132
|
+
exports.choose = choose
|
|
133
|
+
exports.hostOf = hostOf
|
|
134
|
+
exports.locate = locate
|