comq 0.26.0 → 0.28.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/.github/workflows/features.yaml +25 -0
- package/docs/shards.md +72 -0
- package/features/shards.virtual.feature +0 -5
- package/features/steps/call.js +25 -1
- package/features/steps/names.js +0 -10
- package/package.json +1 -1
- package/readme.md +15 -24
- package/source/connect.js +1 -1
- package/source/shards/connection.js +110 -23
- package/source/shards/index.js +0 -2
- package/source/shards/name.js +299 -0
- package/source/shards/resolve.js +12 -13
- package/test/connect.test.js +0 -17
- package/test/shards/connection.test.js +68 -1
- package/test/shards/name.test.js +348 -0
- package/test/shards/resolve.test.js +47 -1
- package/types/connection.d.ts +0 -1
- package/types/shards.d.ts +16 -3
- package/source/shards/expand.js +0 -57
- package/source/shards/watch.js +0 -257
- package/test/shards/expand.test.js +0 -58
- package/test/shards/watch.test.js +0 -264
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Name } = require('../../source/shards/name')
|
|
4
|
+
|
|
5
|
+
/** @type {Record<string, comq.shards.Resolution | Error>} */
|
|
6
|
+
let records
|
|
7
|
+
|
|
8
|
+
/** @type {Record<string, number>} how many times each name has been resolved */
|
|
9
|
+
let resolved
|
|
10
|
+
|
|
11
|
+
/** @type {Name} */
|
|
12
|
+
let name
|
|
13
|
+
|
|
14
|
+
/** @type {comq.shards.Follower & Record<string, jest.MockedFunction<any>>} */
|
|
15
|
+
let follower
|
|
16
|
+
|
|
17
|
+
/** @type {() => void} */
|
|
18
|
+
let leave
|
|
19
|
+
|
|
20
|
+
const INTERVAL = 1000
|
|
21
|
+
const SETTLE = 5000
|
|
22
|
+
const timing = { interval: INTERVAL, settle: SETTLE }
|
|
23
|
+
|
|
24
|
+
const tick = (ms = INTERVAL) => jest.advanceTimersByTimeAsync(ms)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {boolean} [urgent]
|
|
28
|
+
* @return {comq.shards.Follower & Record<string, jest.MockedFunction<any>>}
|
|
29
|
+
*/
|
|
30
|
+
const followerOf = (urgent = false) => ({ change: jest.fn(), error: jest.fn(), urgent: jest.fn(() => urgent) })
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {comq.shards.Timing} [options]
|
|
34
|
+
* @param {boolean} [urgent]
|
|
35
|
+
*/
|
|
36
|
+
async function start (options = timing, urgent = false) {
|
|
37
|
+
name = Name.of('two', options)
|
|
38
|
+
follower = followerOf(urgent)
|
|
39
|
+
leave = name.follow(follower)
|
|
40
|
+
|
|
41
|
+
return await name.start()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
jest.useFakeTimers()
|
|
46
|
+
|
|
47
|
+
records = { one: { addresses: ['10.0.0.1'], ttl: 5 }, two: { addresses: ['10.0.0.2'], ttl: 5 } }
|
|
48
|
+
resolved = { one: 0, two: 0 }
|
|
49
|
+
|
|
50
|
+
global.COMQ_TESTING_LOOKUP = async (host) => {
|
|
51
|
+
resolved[host]++
|
|
52
|
+
|
|
53
|
+
if (records[host] instanceof Error) throw records[host]
|
|
54
|
+
|
|
55
|
+
return records[host]
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
Name.__lets_pretend_this_method_doesnt_exist()
|
|
61
|
+
jest.useRealTimers()
|
|
62
|
+
|
|
63
|
+
delete global.COMQ_TESTING_LOOKUP
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should tell the address of the name', async () => {
|
|
67
|
+
const address = await start()
|
|
68
|
+
|
|
69
|
+
expect(address).toStrictEqual('10.0.0.2')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should not tell of what has not changed', async () => {
|
|
73
|
+
await start()
|
|
74
|
+
await tick(SETTLE * 2)
|
|
75
|
+
|
|
76
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should tell of a change once it has held for the settle time', async () => {
|
|
80
|
+
await start()
|
|
81
|
+
|
|
82
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
83
|
+
|
|
84
|
+
await tick(SETTLE)
|
|
85
|
+
|
|
86
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
87
|
+
|
|
88
|
+
await tick()
|
|
89
|
+
|
|
90
|
+
expect(follower.change).toHaveBeenCalledTimes(1)
|
|
91
|
+
expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.3')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should start the settle time anew whenever the answer changes', async () => {
|
|
95
|
+
await start()
|
|
96
|
+
|
|
97
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
98
|
+
|
|
99
|
+
await tick(SETTLE - INTERVAL)
|
|
100
|
+
|
|
101
|
+
records.two = { addresses: ['10.0.0.4'], ttl: 5 }
|
|
102
|
+
|
|
103
|
+
await tick(SETTLE - INTERVAL)
|
|
104
|
+
|
|
105
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
106
|
+
|
|
107
|
+
await tick(INTERVAL * 2)
|
|
108
|
+
|
|
109
|
+
expect(follower.change).toHaveBeenCalledTimes(1)
|
|
110
|
+
expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.4')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should not act on an answer that goes back and forth', async () => {
|
|
114
|
+
await start()
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < 20; i++) {
|
|
117
|
+
records.two = { addresses: [i % 2 === 0 ? '10.0.0.3' : '10.0.0.2'], ttl: 5 }
|
|
118
|
+
|
|
119
|
+
await tick()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should forget a change that was taken back', async () => {
|
|
126
|
+
await start()
|
|
127
|
+
|
|
128
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
129
|
+
|
|
130
|
+
await tick(INTERVAL * 2)
|
|
131
|
+
|
|
132
|
+
records.two = { addresses: ['10.0.0.2'], ttl: 5 }
|
|
133
|
+
|
|
134
|
+
await tick(SETTLE * 2)
|
|
135
|
+
|
|
136
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should keep the address of a name that fails to resolve', async () => {
|
|
140
|
+
await start()
|
|
141
|
+
|
|
142
|
+
const exception = new Error('ENOTFOUND')
|
|
143
|
+
|
|
144
|
+
records.two = exception
|
|
145
|
+
|
|
146
|
+
await tick(SETTLE * 2)
|
|
147
|
+
|
|
148
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
149
|
+
expect(follower.error).toHaveBeenCalledWith(exception)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('should keep the address of a name that resolves to nothing', async () => {
|
|
153
|
+
await start()
|
|
154
|
+
|
|
155
|
+
records.two = { addresses: [] }
|
|
156
|
+
|
|
157
|
+
await tick(SETTLE * 2)
|
|
158
|
+
|
|
159
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
160
|
+
expect(follower.error).toHaveBeenCalled()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should keep the address in use while it is listed', async () => {
|
|
164
|
+
await start()
|
|
165
|
+
|
|
166
|
+
records.two = { addresses: ['10.0.0.1', '10.0.0.2'], ttl: 5 }
|
|
167
|
+
|
|
168
|
+
await tick(SETTLE * 2)
|
|
169
|
+
|
|
170
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('should take what an urgent name resolves to at once', async () => {
|
|
174
|
+
await start(timing, true)
|
|
175
|
+
|
|
176
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
177
|
+
|
|
178
|
+
await tick()
|
|
179
|
+
|
|
180
|
+
expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.3')
|
|
181
|
+
expect(follower.urgent).toHaveBeenCalledWith('two')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should look at once when poked', async () => {
|
|
185
|
+
await start(timing, true)
|
|
186
|
+
|
|
187
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
188
|
+
|
|
189
|
+
name.poke()
|
|
190
|
+
|
|
191
|
+
await tick(0)
|
|
192
|
+
|
|
193
|
+
expect(follower.change).toHaveBeenCalledTimes(1)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('should resolve until the name is known', async () => {
|
|
197
|
+
global.COMQ_TESTING_RESOLVE_BACKOFF = { base: 10, dispersion: 0 }
|
|
198
|
+
|
|
199
|
+
records.two = new Error('ENOTFOUND')
|
|
200
|
+
|
|
201
|
+
let address
|
|
202
|
+
|
|
203
|
+
const starting = start().then((result) => { address = result })
|
|
204
|
+
|
|
205
|
+
await tick(100)
|
|
206
|
+
|
|
207
|
+
expect(address).toBeUndefined()
|
|
208
|
+
|
|
209
|
+
records.two = { addresses: ['10.0.0.2'], ttl: 5 }
|
|
210
|
+
|
|
211
|
+
await tick(100)
|
|
212
|
+
await starting
|
|
213
|
+
|
|
214
|
+
expect(address).toStrictEqual('10.0.0.2')
|
|
215
|
+
|
|
216
|
+
delete global.COMQ_TESTING_RESOLVE_BACKOFF
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should stop once nobody follows it', async () => {
|
|
220
|
+
await start()
|
|
221
|
+
|
|
222
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
223
|
+
leave()
|
|
224
|
+
|
|
225
|
+
await tick(SETTLE * 2)
|
|
226
|
+
|
|
227
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
228
|
+
expect(jest.getTimerCount()).toStrictEqual(0)
|
|
229
|
+
expect(Name.of('two', timing)).not.toBe(name)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('shared', () => {
|
|
233
|
+
it('should be one for everyone with the same timing', async () => {
|
|
234
|
+
expect(Name.of('two', timing)).toBe(Name.of('two', { ...timing }))
|
|
235
|
+
expect(Name.of('two', timing)).not.toBe(Name.of('two', { interval: INTERVAL * 2 }))
|
|
236
|
+
expect(Name.of('two', timing)).not.toBe(Name.of('one', timing))
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('should be resolved once however many follow it', async () => {
|
|
240
|
+
await start()
|
|
241
|
+
|
|
242
|
+
const others = Array.from({ length: 10 }, () => followerOf())
|
|
243
|
+
|
|
244
|
+
for (const other of others) Name.of('two', timing).follow(other)
|
|
245
|
+
|
|
246
|
+
await Promise.all(others.map(() => Name.of('two', timing).start()))
|
|
247
|
+
|
|
248
|
+
expect(resolved.two).toStrictEqual(1)
|
|
249
|
+
|
|
250
|
+
await tick(INTERVAL * 3)
|
|
251
|
+
|
|
252
|
+
expect(resolved.two).toStrictEqual(4)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('should tell everyone who follows it of a change', async () => {
|
|
256
|
+
await start()
|
|
257
|
+
|
|
258
|
+
const other = followerOf()
|
|
259
|
+
|
|
260
|
+
Name.of('two', timing).follow(other)
|
|
261
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
262
|
+
|
|
263
|
+
await tick(SETTLE + INTERVAL)
|
|
264
|
+
|
|
265
|
+
expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.3')
|
|
266
|
+
expect(other.change).toHaveBeenCalledWith('two', '10.0.0.3')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('should tell one who comes later what is in effect', async () => {
|
|
270
|
+
await start()
|
|
271
|
+
|
|
272
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
273
|
+
|
|
274
|
+
await tick(SETTLE + INTERVAL)
|
|
275
|
+
|
|
276
|
+
Name.of('two', timing).follow(followerOf())
|
|
277
|
+
|
|
278
|
+
expect(await Name.of('two', timing).start()).toStrictEqual('10.0.0.3')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('should be urgent for everyone when it is for one of them', async () => {
|
|
282
|
+
await start()
|
|
283
|
+
|
|
284
|
+
Name.of('two', timing).follow(followerOf(true))
|
|
285
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
286
|
+
|
|
287
|
+
await tick()
|
|
288
|
+
|
|
289
|
+
expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.3')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('should go on while anyone follows it', async () => {
|
|
293
|
+
await start()
|
|
294
|
+
|
|
295
|
+
const other = followerOf()
|
|
296
|
+
|
|
297
|
+
Name.of('two', timing).follow(other)
|
|
298
|
+
leave()
|
|
299
|
+
|
|
300
|
+
records.two = { addresses: ['10.0.0.3'], ttl: 5 }
|
|
301
|
+
|
|
302
|
+
await tick(SETTLE + INTERVAL)
|
|
303
|
+
|
|
304
|
+
expect(other.change).toHaveBeenCalledWith('two', '10.0.0.3')
|
|
305
|
+
expect(follower.change).not.toHaveBeenCalled()
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe('timing', () => {
|
|
310
|
+
it('should settle for the longest TTL seen', async () => {
|
|
311
|
+
records.two = { addresses: ['10.0.0.2'], ttl: 3600 }
|
|
312
|
+
|
|
313
|
+
await start({})
|
|
314
|
+
|
|
315
|
+
records.two = { addresses: ['10.0.0.2'], ttl: 10 }
|
|
316
|
+
|
|
317
|
+
await tick(300_000)
|
|
318
|
+
|
|
319
|
+
expect(name.settle).toStrictEqual(3_600_000)
|
|
320
|
+
expect(name.period).toStrictEqual(3_600_000)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('should settle for no less than 15 minutes', async () => {
|
|
324
|
+
await start({})
|
|
325
|
+
|
|
326
|
+
expect(name.settle).toStrictEqual(900_000)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('should give a name answered without a TTL 30 minutes', async () => {
|
|
330
|
+
records.two = { addresses: ['10.0.0.2'] }
|
|
331
|
+
|
|
332
|
+
await start({})
|
|
333
|
+
|
|
334
|
+
expect(name.settle).toStrictEqual(1_800_000)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('should resolve every 5 minutes unless told otherwise', async () => {
|
|
338
|
+
await start({})
|
|
339
|
+
|
|
340
|
+
expect(name.interval).toStrictEqual(300_000)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('should take what it is told', async () => {
|
|
344
|
+
await start({ interval: 1, settle: 2 })
|
|
345
|
+
|
|
346
|
+
expect([name.interval, name.settle]).toStrictEqual([1, 2])
|
|
347
|
+
})
|
|
348
|
+
})
|
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const dns = require('node:dns').promises
|
|
4
|
+
const { resolve, choose, hostOf, locate } = require('../../source/shards/resolve')
|
|
5
|
+
|
|
6
|
+
describe('resolve', () => {
|
|
7
|
+
/** @type {Record<string, jest.MockedFunction<any>>} */
|
|
8
|
+
let spies
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Array<{ address: string, ttl: number }> | Error} v4
|
|
12
|
+
* @param {Array<{ address: string, ttl: number }> | Error} v6
|
|
13
|
+
* @param {Array<{ address: string }>} [found]
|
|
14
|
+
*/
|
|
15
|
+
const answer = (v4, v6, found = []) => {
|
|
16
|
+
const respond = (value) => async () => { if (value instanceof Error) throw value; return value }
|
|
17
|
+
|
|
18
|
+
spies = {
|
|
19
|
+
resolve4: jest.spyOn(dns, 'resolve4').mockImplementation(respond(v4)),
|
|
20
|
+
resolve6: jest.spyOn(dns, 'resolve6').mockImplementation(respond(v6)),
|
|
21
|
+
lookup: jest.spyOn(dns, 'lookup').mockImplementation(respond(found))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
jest.restoreAllMocks()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should not ask for IPv6 addresses of a name that has IPv4 ones', async () => {
|
|
30
|
+
answer([{ address: '10.0.0.1', ttl: 30 }, { address: '10.0.0.2', ttl: 10 }], [{ address: '::1', ttl: 30 }])
|
|
31
|
+
|
|
32
|
+
expect(await resolve('rmq.example.com')).toStrictEqual({ addresses: ['10.0.0.1', '10.0.0.2'], ttl: 30 })
|
|
33
|
+
expect(spies.resolve6).not.toHaveBeenCalled()
|
|
34
|
+
expect(spies.lookup).not.toHaveBeenCalled()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should ask for IPv6 addresses of a name that has no IPv4 ones', async () => {
|
|
38
|
+
answer(Object.assign(new Error('ENODATA'), { code: 'ENODATA' }), [{ address: '::1', ttl: 30 }])
|
|
39
|
+
|
|
40
|
+
expect(await resolve('rmq.example.com')).toStrictEqual({ addresses: ['::1'], ttl: 30 })
|
|
41
|
+
expect(spies.lookup).not.toHaveBeenCalled()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should look up a name that DNS does not know, with no TTL', async () => {
|
|
45
|
+
answer(new Error('ENOTFOUND'), new Error('ENOTFOUND'), [{ address: '127.0.0.1' }])
|
|
46
|
+
|
|
47
|
+
expect(await resolve('localhost')).toStrictEqual({ addresses: ['127.0.0.1'] })
|
|
48
|
+
})
|
|
49
|
+
})
|
|
4
50
|
|
|
5
51
|
describe('choose', () => {
|
|
6
52
|
it('should choose the same address whichever order they came in', async () => {
|
package/types/connection.d.ts
CHANGED
|
@@ -30,7 +30,6 @@ declare namespace comq {
|
|
|
30
30
|
|
|
31
31
|
type Connect = {
|
|
32
32
|
/**
|
|
33
|
-
* A range in a host is as many URLs: `amqp://rmq[0..32].example.com` is `rmq0` to `rmq31`.
|
|
34
33
|
* Several URLs are the names of the shards, and a connection is made to each broker they
|
|
35
34
|
* stand for.
|
|
36
35
|
*/
|
package/types/shards.d.ts
CHANGED
|
@@ -5,11 +5,12 @@ declare namespace comq {
|
|
|
5
5
|
namespace shards {
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* How the names of the shards are followed, in milliseconds. `settle`
|
|
9
|
-
* to the longest
|
|
8
|
+
* How the names of the shards are followed, in milliseconds. `settle` defaults to the longest
|
|
9
|
+
* TTL the name is answered with, `linger` to the longest of the names, and both to no less
|
|
10
|
+
* than 15 minutes.
|
|
10
11
|
*/
|
|
11
12
|
type Timing = {
|
|
12
|
-
/** how often the names are resolved,
|
|
13
|
+
/** how often the names are resolved, 5 minutes by default */
|
|
13
14
|
interval?: number
|
|
14
15
|
|
|
15
16
|
/** how long an answer is to hold before a broker is joined or retired on it */
|
|
@@ -19,6 +20,18 @@ declare namespace comq {
|
|
|
19
20
|
linger?: number
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** Who follows a name, which is shared by everyone in the process who stands on it. */
|
|
24
|
+
type Follower = {
|
|
25
|
+
/** what the name stands for has changed for good */
|
|
26
|
+
change (host: string, address: string): void
|
|
27
|
+
|
|
28
|
+
/** the name failed to resolve, and keeps its address */
|
|
29
|
+
error (exception: Error): void
|
|
30
|
+
|
|
31
|
+
/** whether the name is of no use as it is resolved now, so that its next answer is taken at once */
|
|
32
|
+
urgent (host: string): boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
type Resolution = {
|
|
23
36
|
addresses: string[]
|
|
24
37
|
|
package/source/shards/expand.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
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
|