@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.
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/composer.js +1 -1
- package/src/core/emitter-manager/mqtt/connection.js +209 -0
- package/src/core/emitter-manager/mqtt/constants.js +43 -29
- package/src/core/emitter-manager/mqtt/emitter.js +103 -658
- package/src/core/emitter-manager/mqtt/health.js +151 -0
- package/src/core/emitter-manager/mqtt/index.js +68 -4
- package/src/core/emitter-manager/mqtt/publish.js +292 -0
- package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
- package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
- package/src/core/emitter-manager/terminal/index.js +16 -1
- package/src/core/env-vars.js +92 -24
- package/src/core/logger/index.js +1 -1
- package/src/core/source-manager/mqtt/client.js +197 -63
- package/src/core/source-manager/mqtt/dedup.js +16 -11
- package/src/core/source-manager/mqtt/index.js +32 -5
- package/src/core/source-manager/mqtt/status.js +168 -56
- package/src/core/storage-manager/questdb/address-policy.js +264 -0
- package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
- package/src/core/storage-manager/questdb/flush-engine.js +852 -0
- package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
- package/src/core/storage-manager/questdb/index.js +542 -862
- package/src/core/storage-manager/questdb/persist-plan.js +128 -167
- package/src/core/storage-manager/questdb/resolve-options.js +266 -0
- package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
- package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
- package/src/core/test-utils/tcp-proxy.js +194 -1
- package/src/core/utils/address/index.js +362 -0
- package/src/core/utils/address/probe.js +210 -0
- package/src/core/utils/callback-guard/index.js +23 -2
- package/src/core/utils/clock/index.js +34 -0
- package/src/core/utils/jitter/index.js +51 -0
- package/src/core/utils/line-rate/index.js +104 -0
- package/src/core/utils/quantize/index.js +43 -19
- package/src/core/wiring/wire-emitters.js +3 -1
- package/src/core/wiring/wire-storages.js +10 -2
- package/src/flow/flow.js +24 -3
- 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
|
|
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
|
+
};
|