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.
Files changed (44) hide show
  1. package/docs/headers.md +16 -1
  2. package/features/properties.feature +39 -0
  3. package/features/shards.virtual.feature +89 -0
  4. package/features/steps/context.js +29 -2
  5. package/features/steps/hooks.js +1 -0
  6. package/features/steps/names.js +245 -0
  7. package/features/steps/networks.js +23 -5
  8. package/features/steps/rpc.js +14 -2
  9. package/package.json +1 -1
  10. package/readme.md +102 -4
  11. package/source/channel.js +147 -2
  12. package/source/connect.js +18 -15
  13. package/source/connection.js +8 -5
  14. package/source/events.js +7 -0
  15. package/source/io.js +31 -9
  16. package/source/shards/channel.js +289 -19
  17. package/source/shards/connection.js +404 -18
  18. package/source/shards/expand.js +57 -0
  19. package/source/shards/index.js +2 -0
  20. package/source/shards/resolve.js +134 -0
  21. package/source/shards/watch.js +257 -0
  22. package/source/singleton.js +3 -2
  23. package/test/amqplib.mock.js +1 -0
  24. package/test/assert.test.js +7 -2
  25. package/test/channel.quiet.test.js +301 -0
  26. package/test/connect.test.js +49 -8
  27. package/test/connection.mock.js +3 -0
  28. package/test/connection.test.js +15 -0
  29. package/test/io.call.test.js +1 -1
  30. package/test/io.diagnostics.test.js +1 -1
  31. package/test/io.reply.test.js +12 -1
  32. package/test/io.request.test.js +18 -0
  33. package/test/io.shards.test.js +85 -0
  34. package/test/shards/channel.test.js +411 -0
  35. package/test/shards/connection.test.js +372 -17
  36. package/test/shards/expand.test.js +58 -0
  37. package/test/shards/resolve.test.js +73 -0
  38. package/test/shards/watch.test.js +264 -0
  39. package/test/singleton.test.js +1 -1
  40. package/types/channel.d.ts +23 -0
  41. package/types/connection.d.ts +13 -2
  42. package/types/diagnostic.d.ts +2 -1
  43. package/types/io.d.ts +17 -1
  44. 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 {comq.Connection[]} */
12
- #connections
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 {comq.Connection[]} connections
53
+ * @param {string[]} urls
54
+ * @param {(url: string, servername?: string) => comq.Connection} create
55
+ * @param {comq.shards.Timing} [timing]
18
56
  */
19
- constructor (connections) {
20
- this.#connections = connections
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
- connections.map(this.#pipe)
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
- const openings = this.#connections.map((connection) => connection.open())
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
- const closing = this.#connections.map((connection) => connection.close())
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
- return channel.create(this.#connections, type)
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
- * @param {comq.Connection} connection
55
- * @param {number} index
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
- #pipe = (connection, index) => {
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
- connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, index))
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 indexes = settled.map((result, index) => result.status === 'fulfilled' ? index : -1)
70
- const opened = indexes.filter((index) => index !== -1)
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
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { Connection } = require('./connection')
4
+ const { expand } = require('./expand')
4
5
 
5
6
  exports.Connection = Connection
7
+ exports.expand = expand
@@ -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