@winkjs/composer 0.6.0 → 0.8.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 (39) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/composer.js +1 -1
  4. package/src/core/emitter-manager/mqtt/connection.js +209 -0
  5. package/src/core/emitter-manager/mqtt/constants.js +43 -29
  6. package/src/core/emitter-manager/mqtt/emitter.js +103 -658
  7. package/src/core/emitter-manager/mqtt/health.js +151 -0
  8. package/src/core/emitter-manager/mqtt/index.js +68 -4
  9. package/src/core/emitter-manager/mqtt/publish.js +292 -0
  10. package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
  11. package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
  12. package/src/core/emitter-manager/terminal/index.js +16 -1
  13. package/src/core/env-vars.js +92 -24
  14. package/src/core/logger/index.js +1 -1
  15. package/src/core/source-manager/mqtt/client.js +197 -63
  16. package/src/core/source-manager/mqtt/dedup.js +16 -11
  17. package/src/core/source-manager/mqtt/index.js +32 -5
  18. package/src/core/source-manager/mqtt/status.js +168 -56
  19. package/src/core/storage-manager/questdb/address-policy.js +264 -0
  20. package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
  21. package/src/core/storage-manager/questdb/flush-engine.js +852 -0
  22. package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
  23. package/src/core/storage-manager/questdb/index.js +542 -862
  24. package/src/core/storage-manager/questdb/persist-plan.js +128 -167
  25. package/src/core/storage-manager/questdb/resolve-options.js +266 -0
  26. package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
  27. package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
  28. package/src/core/test-utils/tcp-proxy.js +194 -1
  29. package/src/core/utils/address/index.js +362 -0
  30. package/src/core/utils/address/probe.js +210 -0
  31. package/src/core/utils/callback-guard/index.js +23 -2
  32. package/src/core/utils/clock/index.js +34 -0
  33. package/src/core/utils/jitter/index.js +51 -0
  34. package/src/core/utils/line-rate/index.js +104 -0
  35. package/src/core/utils/quantize/index.js +43 -19
  36. package/src/core/wiring/wire-emitters.js +3 -1
  37. package/src/core/wiring/wire-storages.js +10 -2
  38. package/src/flow/flow.js +24 -3
  39. package/src/flow/run.js +24 -2
@@ -14,6 +14,15 @@
14
14
  * "service unreachable" without coupling tests to docker daemon
15
15
  * access. The pattern is identical for any TCP-based service.
16
16
  *
17
+ * `startBlackHole()` models the other dead-endpoint shape: a server
18
+ * that accepts the connection and never answers. The QuestDB exit test
19
+ * uses it to hold a request on the wire while the process shuts down.
20
+ *
21
+ * `startHttpResponder()` models a service that is up but answers an
22
+ * error. It forwards to the real service or answers a status code,
23
+ * switched on the live server so no request is reset. The HTTP-error
24
+ * hardening spec uses it.
25
+ *
17
26
  * Cross-cutting test infrastructure mirrors `src/core/source-manager/test-harness/`
18
27
  * — both live under `src/core/` because they're shared across adapter
19
28
  * modules.
@@ -27,6 +36,7 @@
27
36
  * @module test-utils/tcp-proxy
28
37
  */
29
38
 
39
+ import http from 'node:http';
30
40
  import net from 'node:net';
31
41
 
32
42
  /**
@@ -58,6 +68,14 @@ export const startProxy = function ( listenPort, targetPort ) {
58
68
  clientSocket.pipe( targetSocket );
59
69
  targetSocket.pipe( clientSocket );
60
70
  } );
71
+ // The target side is tracked too. A destroyed client socket does
72
+ // not end its pipe partner. Without this, the proxy's own sockets
73
+ // to the service outlive `stopProxy()` and hold the process open.
74
+ // The QuestDB exit test found that.
75
+ sockets.add( targetSocket );
76
+ targetSocket.on( 'close', function () {
77
+ sockets.delete( targetSocket );
78
+ } );
61
79
  // See file-header note on swallowed errors.
62
80
  clientSocket.on( 'error', function () {} );
63
81
  targetSocket.on( 'error', function () {} );
@@ -76,7 +94,8 @@ export const startProxy = function ( listenPort, targetPort ) {
76
94
 
77
95
  /**
78
96
  * Stop a proxy started via `startProxy()`. Force-destroys every
79
- * tracked client socket first (sending RST so clients see the
97
+ * tracked socket first, both the client side and the proxy's own
98
+ * side toward the service (sending RST so clients see the
80
99
  * disconnect immediately, not at next keepalive timeout), then
81
100
  * waits for the server to close cleanly.
82
101
  *
@@ -104,3 +123,177 @@ export const stopProxy = function ( server ) {
104
123
  } );
105
124
  } );
106
125
  };
126
+
127
+ /**
128
+ * Start a black hole on `port`: a server that accepts every connection
129
+ * and never answers. It reads and discards whatever the client sends,
130
+ * so the client's write completes and the client then waits for a
131
+ * response that never comes. That is the shape of an endpoint whose
132
+ * process is alive but wedged. A closed port has a different shape:
133
+ * it refuses at once.
134
+ *
135
+ * The hole has a second mode. After `forwardTo( targetPort )` every
136
+ * new connection is relayed to that port, like `startProxy()`. After
137
+ * `swallow()` the hole is back: new connections are swallowed, and a
138
+ * connection that was being relayed goes quiet from that instant. Its
139
+ * service side is dropped and its client side stays open, so a
140
+ * request already on a kept-alive socket waits for an answer that
141
+ * never comes. The switch happens on the live server, so the port
142
+ * never closes and no request on the wire is reset. A port bounce
143
+ * would reset one, and the client reports a reset as a socket error,
144
+ * not as a hang.
145
+ *
146
+ * Returns the `net.Server` once it is listening, with `forwardTo()`
147
+ * and `swallow()` attached; pass it to `stopProxy()` to tear down,
148
+ * sockets included.
149
+ *
150
+ * @param {number} port
151
+ * @returns {Promise<net.Server>}
152
+ */
153
+ export const startBlackHole = function ( port ) {
154
+ const sockets = new Set();
155
+ // Client socket → service socket, for the connections being relayed.
156
+ const relayed = new Map();
157
+ let targetPort = null;
158
+
159
+ const track = function ( socket ) {
160
+ sockets.add( socket );
161
+ socket.on( 'close', function () {
162
+ sockets.delete( socket );
163
+ } );
164
+ // See file-header note on swallowed errors.
165
+ socket.on( 'error', function () {} );
166
+ };
167
+
168
+ const relay = function ( clientSocket, servicePort ) {
169
+ const targetSocket = net.connect( servicePort, '127.0.0.1', function () {
170
+ clientSocket.pipe( targetSocket );
171
+ targetSocket.pipe( clientSocket );
172
+ } );
173
+ track( targetSocket );
174
+ relayed.set( clientSocket, targetSocket );
175
+ clientSocket.on( 'close', function () {
176
+ relayed.delete( clientSocket );
177
+ targetSocket.destroy();
178
+ } );
179
+ };
180
+
181
+ const server = net.createServer( function ( clientSocket ) {
182
+ track( clientSocket );
183
+ if ( targetPort === null ) {
184
+ // Consume the request bytes so the client's write completes.
185
+ clientSocket.resume();
186
+ return;
187
+ }
188
+ relay( clientSocket, targetPort );
189
+ } );
190
+ server.forwardTo = function ( servicePort ) {
191
+ targetPort = servicePort;
192
+ };
193
+ server.swallow = function () {
194
+ targetPort = null;
195
+ for ( const [ clientSocket, targetSocket ] of relayed ) {
196
+ clientSocket.unpipe( targetSocket );
197
+ targetSocket.unpipe( clientSocket );
198
+ targetSocket.destroy();
199
+ clientSocket.resume();
200
+ }
201
+ relayed.clear();
202
+ };
203
+ // eslint-disable-next-line no-underscore-dangle
204
+ server._sockets = sockets;
205
+ return new Promise( function ( resolve ) {
206
+ server.listen( port, '127.0.0.1', function () {
207
+ resolve( server );
208
+ } );
209
+ } );
210
+ };
211
+
212
+ /**
213
+ * Start an HTTP responder on `port`. It has two modes, switched on the
214
+ * live server, so the port never closes between them. In answer mode
215
+ * it reads every request and answers it with `statusCode` and a short
216
+ * plain-text body. That is the shape of a service that is up but
217
+ * refuses the request, such as a 400 for a malformed row or a 500 for
218
+ * an internal failure. In forward mode it relays each request to a
219
+ * real service on `127.0.0.1:<upstreamPort>` and returns that
220
+ * service's answer. Every answer closes the connection.
221
+ *
222
+ * Why a mode switch and not a port bounce: closing a server destroys
223
+ * its sockets, so a request on the wire at that instant reads a reset
224
+ * instead of an answer. A switch on a live server resets nothing. Each
225
+ * request gets the mode that was active when it arrived.
226
+ *
227
+ * The QuestDB client treats some codes as retryable and the rest as
228
+ * final; the HTTP-error hardening spec pins both against this
229
+ * responder. Returns the server once it is listening, with two extra
230
+ * methods: `answerWith( statusCode )` and `forwardTo( upstreamPort )`.
231
+ * Pass the server to `stopProxy()` to tear down, sockets included.
232
+ *
233
+ * @param {number} port
234
+ * @param {number} statusCode - answered until `forwardTo()` is called
235
+ * @returns {Promise<http.Server>}
236
+ */
237
+ export const startHttpResponder = function ( port, statusCode ) {
238
+ const sockets = new Set();
239
+ let mode = { answer: statusCode, forward: null };
240
+
241
+ const answer = function ( req, res, code ) {
242
+ req.resume();
243
+ req.on( 'end', function () {
244
+ res.writeHead( code, { 'Content-Type': 'text/plain', Connection: 'close' } );
245
+ res.end( `responder answered ${code}` );
246
+ } );
247
+ };
248
+
249
+ const forward = function ( req, res, upstreamPort ) {
250
+ const headers = Object.assign( {}, req.headers );
251
+ delete headers.connection;
252
+ const upstream = http.request( {
253
+ host: '127.0.0.1',
254
+ port: upstreamPort,
255
+ method: req.method,
256
+ path: req.url,
257
+ headers,
258
+ agent: false
259
+ }, function ( upstreamRes ) {
260
+ res.writeHead( upstreamRes.statusCode, Object.assign( {}, upstreamRes.headers, { connection: 'close' } ) );
261
+ upstreamRes.pipe( res );
262
+ } );
263
+ // See file-header note on swallowed errors.
264
+ upstream.on( 'error', function () {
265
+ res.destroy();
266
+ } );
267
+ req.pipe( upstream );
268
+ };
269
+
270
+ const server = http.createServer( function ( req, res ) {
271
+ const current = mode;
272
+ if ( current.forward === null ) {
273
+ answer( req, res, current.answer );
274
+ return;
275
+ }
276
+ forward( req, res, current.forward );
277
+ } );
278
+ server.answerWith = function ( code ) {
279
+ mode = { answer: code, forward: null };
280
+ };
281
+ server.forwardTo = function ( upstreamPort ) {
282
+ mode = { answer: null, forward: upstreamPort };
283
+ };
284
+ server.on( 'connection', function ( socket ) {
285
+ sockets.add( socket );
286
+ socket.on( 'close', function () {
287
+ sockets.delete( socket );
288
+ } );
289
+ // See file-header note on swallowed errors.
290
+ socket.on( 'error', function () {} );
291
+ } );
292
+ // eslint-disable-next-line no-underscore-dangle
293
+ server._sockets = sockets;
294
+ return new Promise( function ( resolve ) {
295
+ server.listen( port, '127.0.0.1', function () {
296
+ resolve( server );
297
+ } );
298
+ } );
299
+ };
@@ -0,0 +1,362 @@
1
+ // core/utils/address/index.js
2
+
3
+ /**
4
+ * @fileoverview The shared address helper: parse, classify, and describe
5
+ * the addresses adapters connect to.
6
+ *
7
+ * Every adapter takes an address. QuestDB takes `ilpUrl` and `pgUrl`
8
+ * in the `host:port` grammar. The two MQTT adapters take `brokerUrl`
9
+ * in the URL grammar (`mqtt://host:port`). This module is the one
10
+ * place that reads those strings, so the three refusal layers (the
11
+ * adapter schema, the adapter factory, and `env-vars.js`) and the
12
+ * three adapters all reach the same verdict about one value.
13
+ *
14
+ * Why it exists (ADR-030): `localhost` is a name, not an address. On
15
+ * most systems it resolves to two addresses, `::1` and `127.0.0.1`,
16
+ * and a service may listen on only one. The run-5 soak lost its write
17
+ * path for four hours that way. So `localhost` is refused wherever an
18
+ * adapter address is read, and any other name gets one warning. This
19
+ * module supplies the classification and the two message strings; it
20
+ * never prints and never opens a socket. The setup probe that does open
21
+ * a socket lives beside it in `probe.js` (ADR-030 item 4), so this file
22
+ * stays pure.
23
+ *
24
+ * Classification rules:
25
+ * - An IP literal (`net.isIP` returns 4 or 6) is `ip`.
26
+ * - `localhost` in any letter case, with or without a trailing dot, is
27
+ * `localhost`. So is any name under the reserved `.localhost`
28
+ * top-level domain (RFC 6761 §6.3 reserves it for loopback).
29
+ * - Everything else is `name`.
30
+ * - A value the grammar cannot read is `unparsed`. It is neither
31
+ * refused nor warned here; the transport library owns that error.
32
+ *
33
+ * The functions never throw on an address value. `classifyAddress`
34
+ * throws only on an unknown grammar, which is a programming error.
35
+ *
36
+ * Setup path only. Nothing here runs per message.
37
+ */
38
+
39
+ import net from 'node:net';
40
+
41
+ const LOOPBACK_LITERAL = '127.0.0.1';
42
+ const MAX_PORT = 65535;
43
+ const LOCALHOST = 'localhost';
44
+ const LOCALHOST_SUFFIX = '.localhost';
45
+ const GRAMMARS = new Set( [ 'hostPort', 'url' ] );
46
+
47
+ // A host in the plain form has no colon, slash, or whitespace. The
48
+ // port is digits only. Both patterns are single-quantifier on purpose:
49
+ // the security lint rejects a nested quantifier, so the host and the
50
+ // port are split by hand on the first colon and each part is tested
51
+ // alone.
52
+ const HOST_CHARS = /^[^:\/\s]+$/;
53
+ const DIGITS = /^\d+$/;
54
+
55
+ const MULTI_ADDRESS_REASON = 'can resolve to more than one address, and the service may answer on only one';
56
+
57
+ /**
58
+ * The result every parser returns for a value it cannot read.
59
+ *
60
+ * @param {*} value - The original value, echoed for the caller's message
61
+ * @returns {{unparsed: true, value: *}} The unparsed marker
62
+ */
63
+ const unparsed = function ( value ) {
64
+ return { unparsed: true, value };
65
+ }; // unparsed()
66
+
67
+ /**
68
+ * Completes a host:port parse once the host and the port text are
69
+ * known. The port must be digits only and at most 65535; otherwise the
70
+ * whole value is unparsed.
71
+ *
72
+ * @param {*} value - The original value, for the unparsed marker
73
+ * @param {string} host - The host text, brackets already removed
74
+ * @param {string|undefined} portText - The text after the colon, or undefined
75
+ * @param {boolean} bracketed - Whether the host was written in brackets
76
+ * @returns {Object} `{ host, port, bracketed }` or the unparsed marker
77
+ */
78
+ const finishHostPort = function ( value, host, portText, bracketed ) {
79
+ if ( portText === undefined ) {
80
+ return { host, port: undefined, bracketed };
81
+ }
82
+ if ( !DIGITS.test( portText ) ) {
83
+ return unparsed( value );
84
+ }
85
+ const port = Number( portText );
86
+ if ( port > MAX_PORT ) {
87
+ return unparsed( value );
88
+ }
89
+ return { host, port, bracketed };
90
+ }; // finishHostPort()
91
+
92
+ /**
93
+ * Parses the bracketed form: `[v6]` or `[v6]:port`. The brackets must
94
+ * hold an IPv6 literal, and only `:port` may follow them.
95
+ *
96
+ * @param {*} value - The original value, for the unparsed marker
97
+ * @param {string} text - The trimmed value, known to start with `[`
98
+ * @returns {Object} `{ host, port, bracketed }` or the unparsed marker
99
+ */
100
+ const parseBracketed = function ( value, text ) {
101
+ const close = text.indexOf( ']' );
102
+ if ( close === -1 ) {
103
+ return unparsed( value );
104
+ }
105
+ const host = text.slice( 1, close );
106
+ if ( net.isIP( host ) !== 6 ) {
107
+ return unparsed( value );
108
+ }
109
+ const rest = text.slice( close + 1 );
110
+ if ( rest === '' ) {
111
+ return { host, port: undefined, bracketed: true };
112
+ }
113
+ if ( !rest.startsWith( ':' ) ) {
114
+ return unparsed( value );
115
+ }
116
+ return finishHostPort( value, host, rest.slice( 1 ), true );
117
+ }; // parseBracketed()
118
+
119
+ /**
120
+ * Parses the `host:port` grammar. Accepts `host:port`, `[v6]:port`,
121
+ * a bare host, a bare bracketed IPv6 literal, and a bare unbracketed
122
+ * IPv6 literal. The port is optional in every form.
123
+ *
124
+ * @param {*} value - The configured value
125
+ * @returns {Object} `{ host, port, bracketed }` or `{ unparsed: true, value }`
126
+ */
127
+ const parseHostPort = function ( value ) {
128
+ if ( typeof value !== 'string' ) {
129
+ return unparsed( value );
130
+ }
131
+ const text = value.trim();
132
+ if ( text === '' ) {
133
+ return unparsed( value );
134
+ }
135
+ if ( text.startsWith( '[' ) ) {
136
+ return parseBracketed( value, text );
137
+ }
138
+
139
+ // A bare IPv6 literal has colons of its own, so it cannot carry a
140
+ // port in this grammar. Write `[v6]:port` for that.
141
+ if ( net.isIP( text ) === 6 ) {
142
+ return { host: text, port: undefined, bracketed: false };
143
+ }
144
+
145
+ // The plain form splits on the first colon. A second colon then
146
+ // lands in the port text, where the digits test rejects it.
147
+ const at = text.indexOf( ':' );
148
+ const host = ( at === -1 ) ? text : text.slice( 0, at );
149
+ const portText = ( at === -1 ) ? undefined : text.slice( at + 1 );
150
+ if ( !HOST_CHARS.test( host ) ) {
151
+ return unparsed( value );
152
+ }
153
+ return finishHostPort( value, host, portText, false );
154
+ }; // parseHostPort()
155
+
156
+ /**
157
+ * Parses the broker URL grammar with the platform URL parser. The
158
+ * brackets around an IPv6 host are removed. Userinfo (a user name or
159
+ * password in the URL) is reported as a flag and never copied out, so
160
+ * a password cannot travel into a message.
161
+ *
162
+ * @param {*} value - The configured value
163
+ * @returns {Object} `{ protocol, host, port, bracketed, hasUserinfo }` or `{ unparsed: true, value }`
164
+ */
165
+ const parseBrokerUrl = function ( value ) {
166
+ if ( typeof value !== 'string' ) {
167
+ return unparsed( value );
168
+ }
169
+ let url;
170
+ try {
171
+ url = new URL( value.trim() );
172
+ } catch {
173
+ return unparsed( value );
174
+ }
175
+ if ( url.hostname === '' ) {
176
+ return unparsed( value );
177
+ }
178
+
179
+ const bracketed = url.hostname.startsWith( '[' );
180
+ const host = bracketed ? url.hostname.slice( 1, -1 ) : url.hostname;
181
+ const port = ( url.port === '' ) ? undefined : Number( url.port );
182
+ const hasUserinfo = ( url.username !== '' ) || ( url.password !== '' );
183
+ return { protocol: url.protocol, host, port, bracketed, hasUserinfo };
184
+ }; // parseBrokerUrl()
185
+
186
+ /**
187
+ * Classifies one host as an IP literal, `localhost`, or a name.
188
+ *
189
+ * @param {string} host - A parsed host, brackets already removed
190
+ * @returns {'ip'|'localhost'|'name'} The classification
191
+ */
192
+ const classifyHost = function ( host ) {
193
+ if ( net.isIP( host ) !== 0 ) {
194
+ return 'ip';
195
+ }
196
+ const lowered = host.toLowerCase();
197
+ const normalised = lowered.endsWith( '.' ) ? lowered.slice( 0, -1 ) : lowered;
198
+ if ( ( normalised === LOCALHOST ) || normalised.endsWith( LOCALHOST_SUFFIX ) ) {
199
+ return 'localhost';
200
+ }
201
+ return 'name';
202
+ }; // classifyHost()
203
+
204
+ /**
205
+ * Parses and classifies one configured address in one call. This is
206
+ * the one check the three refusal layers share.
207
+ *
208
+ * @param {*} value - The configured value
209
+ * @param {'hostPort'|'url'} grammar - Which grammar the field uses
210
+ * @returns {Object} `{ kind, grammar, host, port, family, bracketed }`,
211
+ * plus `protocol` and `hasUserinfo` for the URL grammar. A value the
212
+ * grammar cannot read returns `{ kind: 'unparsed', grammar, value }`.
213
+ * @throws {TypeError} On an unknown grammar (a programming error)
214
+ */
215
+ const classifyAddress = function ( value, grammar ) {
216
+ if ( !GRAMMARS.has( grammar ) ) {
217
+ throw new TypeError( `winkComposer/address: unknown grammar '${grammar}' (expected hostPort or url)` );
218
+ }
219
+ const parsed = ( grammar === 'url' ) ? parseBrokerUrl( value ) : parseHostPort( value );
220
+ if ( parsed.unparsed ) {
221
+ return { kind: 'unparsed', grammar, value };
222
+ }
223
+ const address = {
224
+ kind: classifyHost( parsed.host ),
225
+ grammar,
226
+ host: parsed.host,
227
+ port: parsed.port,
228
+ family: net.isIP( parsed.host ),
229
+ bracketed: parsed.bracketed
230
+ };
231
+ if ( grammar === 'url' ) {
232
+ address.protocol = parsed.protocol;
233
+ address.hasUserinfo = parsed.hasUserinfo;
234
+ }
235
+ return address;
236
+ }; // classifyAddress()
237
+
238
+ /**
239
+ * Writes a host the way a config value would: an IPv6 literal in
240
+ * brackets, anything else as is.
241
+ *
242
+ * @param {string} host - The host text
243
+ * @returns {string} The host, bracketed when it is an IPv6 literal
244
+ */
245
+ const bracketIfIPv6 = function ( host ) {
246
+ return ( net.isIP( host ) === 6 ) ? `[${host}]` : host;
247
+ }; // bracketIfIPv6()
248
+
249
+ /**
250
+ * Joins a host text and an optional port.
251
+ *
252
+ * @param {string} hostText - The host, already bracketed if needed
253
+ * @param {number|undefined} port - The port, or undefined for none
254
+ * @returns {string} `host` or `host:port`
255
+ */
256
+ const withPort = function ( hostText, port ) {
257
+ return ( port === undefined ) ? hostText : `${hostText}:${port}`;
258
+ }; // withPort()
259
+
260
+ /**
261
+ * Writes a host and an optional port the way a config value would:
262
+ * an IPv6 host in brackets, `host:port` when a port is given.
263
+ *
264
+ * @param {string} host - The host text
265
+ * @param {number|undefined} port - The port, or undefined for none
266
+ * @returns {string} `host`, `host:port`, or `[v6]:port`
267
+ */
268
+ const formatHostPort = function ( host, port ) {
269
+ return withPort( bracketIfIPv6( host ), port );
270
+ }; // formatHostPort()
271
+
272
+ /**
273
+ * Renders a classified address for a message. Userinfo in a URL is
274
+ * shown as `***@`, never as written.
275
+ *
276
+ * @param {Object} address - A `classifyAddress` result (not unparsed)
277
+ * @returns {string} The address as an operator would write it
278
+ */
279
+ const formatAddress = function ( address ) {
280
+ const hostPort = formatHostPort( address.host, address.port );
281
+ if ( address.grammar === 'url' ) {
282
+ const userinfo = address.hasUserinfo ? '***@' : '';
283
+ return `${address.protocol}//${userinfo}${hostPort}`;
284
+ }
285
+ return hostPort;
286
+ }; // formatAddress()
287
+
288
+ /**
289
+ * Suggests the same address with a literal host in place of the one
290
+ * configured. A URL suggestion drops userinfo, so a password never
291
+ * lands in a message.
292
+ *
293
+ * @param {Object} address - A `classifyAddress` result (not unparsed)
294
+ * @param {string} [literalHost='127.0.0.1'] - The literal to suggest
295
+ * @returns {string} The address to set
296
+ */
297
+ const suggestLiteral = function ( address, literalHost = LOOPBACK_LITERAL ) {
298
+ const hostPort = formatHostPort( literalHost, address.port );
299
+ if ( address.grammar === 'url' ) {
300
+ return `${address.protocol}//${hostPort}`;
301
+ }
302
+ return hostPort;
303
+ }; // suggestLiteral()
304
+
305
+ /**
306
+ * The message clause for a refused `localhost` address, in the ADR-028
307
+ * form `<message> [INVALID_CONFIG]: <detail>`. The caller prefixes its
308
+ * module token. Names the field, the literal to set, and the env var
309
+ * when the field has one.
310
+ *
311
+ * @param {Object} args - Message inputs
312
+ * @param {string} args.field - The config key (`ilpUrl`, `brokerUrl`)
313
+ * @param {Object} args.address - The classified address being refused
314
+ * @param {string} [args.envVar] - The environment variable that also sets it
315
+ * @returns {string} The message clause
316
+ */
317
+ const localhostRefusalMessage = function ( { field, address, envVar } ) {
318
+ const literal = suggestLiteral( address );
319
+ const where = ( envVar === undefined ) ? '' : ` (or ${envVar}=${literal})`;
320
+ return `${field} '${formatAddress( address )}' is refused [INVALID_CONFIG]: ` +
321
+ `'localhost' ${MULTI_ADDRESS_REASON}; set ${field} to ${literal}${where}`;
322
+ }; // localhostRefusalMessage()
323
+
324
+ /**
325
+ * The detail for `env-vars.js`, whose runner prefixes the variable
326
+ * name itself.
327
+ *
328
+ * @param {Object} address - The classified address being refused
329
+ * @returns {string} The detail sentence
330
+ */
331
+ const localhostRefusalDetail = function ( address ) {
332
+ return `'localhost' ${MULTI_ADDRESS_REASON}; use ${suggestLiteral( address )}`;
333
+ }; // localhostRefusalDetail()
334
+
335
+ /**
336
+ * The warning clause for a host that is a name. `ADDRESS_IS_NAME` is a
337
+ * console classification token, like `CALLBACK_FAILED`, not an
338
+ * `err.code` (ADR-030). The caller prefixes its module token.
339
+ *
340
+ * @param {Object} args - Message inputs
341
+ * @param {string} args.field - The config key
342
+ * @param {string} args.host - The name that was configured
343
+ * @returns {string} The warning clause
344
+ */
345
+ const nameWarningMessage = function ( { field, host } ) {
346
+ return `${field} host '${host}' is a name, not an address [ADDRESS_IS_NAME]: ` +
347
+ `a name ${MULTI_ADDRESS_REASON}; prefer the literal address`;
348
+ }; // nameWarningMessage()
349
+
350
+ export {
351
+ parseHostPort,
352
+ parseBrokerUrl,
353
+ classifyHost,
354
+ classifyAddress,
355
+ formatHostPort,
356
+ formatAddress,
357
+ suggestLiteral,
358
+ localhostRefusalMessage,
359
+ localhostRefusalDetail,
360
+ nameWarningMessage,
361
+ LOOPBACK_LITERAL
362
+ };