Skip to content

Commit faf2185

Browse files
authored
feat: add support for loadBalanced TXT option MONGOSH-819 (#1)
2 parents 6aea86b + 937d706 commit faf2185

File tree

3 files changed

+90
-56
lines changed

3 files changed

+90
-56
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# resolve-mongodb-srv
22

3-
Resolve mongodb+srv:// URLs to mongodb:// URLs
3+
Resolve `mongodb+srv://` URLs to `mongodb://` URLs as specified in the
4+
[Initial DNS Seedlist Discovery Specification](https://github.com/mongodb/specifications/blob/master/source/initial-dns-seedlist-discovery/initial-dns-seedlist-discovery.rst).
45

56
```js
67
import resolveMongodbSrv from 'resolve-mongodb-srv';

src/index.ts

Lines changed: 60 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,78 @@
1+
import { promisify } from 'util';
12
import { URL, URLSearchParams } from 'whatwg-url';
23

34
class MongoParseError extends Error {}
45

6+
type Address = { name: string; port: number };
57
type Options = {
68
dns?: {
7-
resolveSrv(hostname: string, cb: (err: Error | undefined | null, addresses: { name: string, port: number }[] | undefined) => void): void;
9+
resolveSrv(hostname: string, cb: (err: Error | undefined | null, addresses: Address[] | undefined) => void): void;
810
resolveTxt(hostname: string, cb: (err: Error | undefined | null, addresses: string[][] | undefined) => void): void;
911
};
1012
};
1113

14+
const ALLOWED_TXT_OPTIONS: Readonly<string[]> = ['authSource', 'replicaSet', 'loadBalanced'];
15+
1216
function matchesParentDomain (srvAddress: string, parentDomain: string): boolean {
1317
const regex = /^.*?\./;
1418
const srv = `.${srvAddress.replace(regex, '')}`;
1519
const parent = `.${parentDomain.replace(regex, '')}`;
1620
return srv.endsWith(parent);
1721
}
1822

23+
async function resolveDnsSrvRecord (dns: NonNullable<Options['dns']>, lookupAddress: string): Promise<string[]> {
24+
const addresses = await promisify(dns.resolveSrv)(`_mongodb._tcp.${lookupAddress}`);
25+
if (!addresses?.length) {
26+
throw new MongoParseError('No addresses found at host');
27+
}
28+
29+
for (const { name } of addresses) {
30+
if (!matchesParentDomain(name, lookupAddress)) {
31+
throw new MongoParseError('Server record does not share hostname with parent URI');
32+
}
33+
}
34+
35+
return addresses.map(r => r.name + ((r.port ?? 27017) === 27017 ? '' : `:${r.port}`));
36+
}
37+
38+
async function resolveDnsTxtRecord (dns: NonNullable<Options['dns']>, lookupAddress: string): Promise<URLSearchParams> {
39+
let records: string[][] | undefined;
40+
try {
41+
records = await promisify(dns.resolveTxt)(lookupAddress);
42+
} catch (err) {
43+
if (err.code && (err.code !== 'ENODATA' && err.code !== 'ENOTFOUND')) {
44+
throw err;
45+
}
46+
}
47+
48+
let txtRecord: string;
49+
if (records && records.length > 1) {
50+
throw new MongoParseError('Multiple text records not allowed');
51+
} else {
52+
txtRecord = records?.[0]?.join('') ?? '';
53+
}
54+
55+
const txtRecordOptions = new URLSearchParams(txtRecord);
56+
const txtRecordOptionKeys = [...txtRecordOptions.keys()];
57+
if (txtRecordOptionKeys.some(key => !ALLOWED_TXT_OPTIONS.includes(key))) {
58+
throw new MongoParseError(`Text record must only set ${ALLOWED_TXT_OPTIONS.join(', ')}`);
59+
}
60+
61+
const source = txtRecordOptions.get('authSource') ?? undefined;
62+
const replicaSet = txtRecordOptions.get('replicaSet') ?? undefined;
63+
const loadBalanced = txtRecordOptions.get('loadBalanced') ?? undefined;
64+
65+
if (source === '' || replicaSet === '' || loadBalanced === '') {
66+
throw new MongoParseError('Cannot have empty URI params in DNS TXT Record');
67+
}
68+
69+
if (loadBalanced !== undefined && loadBalanced !== 'true' && loadBalanced !== 'false') {
70+
throw new MongoParseError(`DNS TXT Record contains invalid value ${loadBalanced} for loadBalanced option (allowed: true, false)`);
71+
}
72+
73+
return txtRecordOptions;
74+
}
75+
1976
async function resolveMongodbSrv (input: string, options?: Options): Promise<string> {
2077
const dns = options?.dns ?? require('dns');
2178

@@ -33,60 +90,8 @@ async function resolveMongodbSrv (input: string, options?: Options): Promise<str
3390

3491
const lookupAddress = url.hostname;
3592
const [srvResult, txtResult] = await Promise.all([
36-
(async () => {
37-
const addresses = await new Promise<{name: string, port: number}[]>((resolve, reject) => {
38-
dns.resolveSrv(`_mongodb._tcp.${lookupAddress}`,
39-
(err: Error | null, addresses: { name: string, port: number }[]) => {
40-
if (err) return reject(err);
41-
return resolve(addresses);
42-
});
43-
});
44-
if (addresses.length === 0) {
45-
throw new MongoParseError('No addresses found at host');
46-
}
47-
48-
for (const { name } of addresses) {
49-
if (!matchesParentDomain(name, lookupAddress)) {
50-
throw new MongoParseError('Server record does not share hostname with parent URI');
51-
}
52-
}
53-
54-
return addresses.map(r => r.name + ((r.port ?? 27017) === 27017 ? '' : `:${r.port}`));
55-
})(),
56-
(async () => {
57-
const txtRecord = await new Promise<string>((resolve, reject) => {
58-
dns.resolveTxt(lookupAddress, (err: (Error & { code: string }) | null, record: string[][]) => {
59-
if (err) {
60-
if (err.code && (err.code !== 'ENODATA' && err.code !== 'ENOTFOUND')) {
61-
reject(err);
62-
} else {
63-
resolve('');
64-
}
65-
} else {
66-
if (record.length > 1) {
67-
reject(new MongoParseError('Multiple text records not allowed'));
68-
} else {
69-
resolve(record[0]?.join('') ?? '');
70-
}
71-
}
72-
});
73-
});
74-
75-
const txtRecordOptions = new URLSearchParams(txtRecord);
76-
const txtRecordOptionKeys = [...txtRecordOptions.keys()];
77-
if (txtRecordOptionKeys.some(key => key !== 'authSource' && key !== 'replicaSet')) {
78-
throw new MongoParseError('Text record must only set `authSource` or `replicaSet`');
79-
}
80-
81-
const source = txtRecordOptions.get('authSource') ?? undefined;
82-
const replicaSet = txtRecordOptions.get('replicaSet') ?? undefined;
83-
84-
if (source === '' || replicaSet === '') {
85-
throw new MongoParseError('Cannot have empty URI params in DNS TXT Record');
86-
}
87-
88-
return txtRecordOptions;
89-
})()
93+
resolveDnsSrvRecord(dns, lookupAddress),
94+
resolveDnsTxtRecord(dns, lookupAddress)
9095
]);
9196

9297
url.protocol = 'mongodb:';

test/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,34 @@ describe('resolveMongodbSrv', () => {
162162
await resolveMongodbSrv('mongodb+srv://server.example.com/?tls=false', { dns }),
163163
'mongodb://asdf.example.com/?tls=false');
164164
});
165+
166+
it('accepts TXT lookup loadBalanced', async () => {
167+
srvResult = [{ name: 'asdf.example.com', port: 27017 }];
168+
txtResult = [['loadBalanced=true']];
169+
assert.strictEqual(
170+
await resolveMongodbSrv('mongodb+srv://server.example.com', { dns }),
171+
'mongodb://asdf.example.com/?loadBalanced=true&tls=true');
172+
});
173+
174+
it('rejects empty TXT lookup loadBalanced', async () => {
175+
srvResult = [{ name: 'asdf.example.com', port: 27017 }];
176+
txtResult = [['loadBalanced=']];
177+
assert.rejects(resolveMongodbSrv('mongodb+srv://server.example.com', { dns }));
178+
});
179+
180+
it('rejects non true/false TXT lookup loadBalanced', async () => {
181+
srvResult = [{ name: 'asdf.example.com', port: 27017 }];
182+
txtResult = [['loadBalanced=bla']];
183+
assert.rejects(resolveMongodbSrv('mongodb+srv://server.example.com', { dns }));
184+
});
185+
186+
it('prioritizes URL-provided over TXT lookup loadBalanced', async () => {
187+
srvResult = [{ name: 'asdf.example.com', port: 27017 }];
188+
txtResult = [['loadBalanced=false']];
189+
assert.strictEqual(
190+
await resolveMongodbSrv('mongodb+srv://server.example.com/?loadBalanced=true', { dns }),
191+
'mongodb://asdf.example.com/?loadBalanced=true&tls=true');
192+
});
165193
});
166194

167195
for (const [name, dnsProvider] of [

0 commit comments

Comments
 (0)