@winkjs/composer 0.5.1 → 0.7.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 +1 -1
- package/package.json +3 -3
- package/src/composer.js +1 -1
- package/src/core/batch/aoo-encoder.js +4 -4
- package/src/core/batch/sliding-window.js +2 -2
- package/src/core/codec/index.js +4 -4
- 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 +108 -633
- 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/assert-columns.js +1 -1
- package/src/core/emitter-manager/terminal/index.js +20 -5
- package/src/core/env-vars.js +143 -16
- package/src/core/logger/index.js +174 -0
- package/src/core/mqtt-protocol.js +2 -2
- package/src/core/partition-manager/get-stats.js +31 -0
- package/src/core/partition-manager/index.js +1 -0
- package/src/core/partition-manager/init.js +15 -0
- package/src/core/partition-manager/update.js +143 -87
- package/src/core/partition-manager/validate-flow.js +1 -1
- package/src/core/semantics/loader.js +1 -1
- package/src/core/semantics/warnings.js +5 -3
- package/src/core/shutdown-manager/index.js +71 -21
- package/src/core/source-manager/csv/start.js +109 -35
- package/src/core/source-manager/mqtt/client.js +276 -83
- package/src/core/source-manager/mqtt/dedup.js +19 -14
- package/src/core/source-manager/mqtt/index.js +33 -6
- package/src/core/source-manager/mqtt/status.js +211 -46
- package/src/core/source-manager/record-shape.js +55 -0
- package/src/core/source-manager/test-harness/field-generator.js +1 -1
- package/src/core/source-manager/test-harness/start.js +52 -10
- package/src/core/source-manager/test-harness/validate.js +1 -1
- package/src/core/storage-manager/questdb/address-policy.js +264 -0
- package/src/core/storage-manager/questdb/assert-columns.js +2 -2
- package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
- package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
- 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 +592 -827
- package/src/core/storage-manager/questdb/persist-plan.js +147 -162
- package/src/core/storage-manager/questdb/resolve-options.js +403 -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/tunable/helpers.js +1 -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 +176 -0
- package/src/core/utils/clock/index.js +34 -0
- package/src/core/utils/half-life/index.js +4 -4
- 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/utils/validate/composer-validators.js +2 -2
- package/src/core/utils/validate/index.js +1 -1
- package/src/core/utils/validate/validate.js +1 -1
- package/src/core/wiring/assert-handle.js +5 -5
- package/src/core/wiring/assert-module.js +2 -2
- package/src/core/wiring/wire-emitters.js +7 -4
- package/src/core/wiring/wire-linear-graph.js +1 -1
- package/src/core/wiring/wire-node.js +6 -6
- package/src/core/wiring/wire-semantics.js +1 -1
- package/src/core/wiring/wire-storages.js +14 -5
- package/src/flow/driver.js +31 -6
- package/src/flow/expand-single-input-array.js +4 -4
- package/src/flow/flow-api-schemas.js +4 -4
- package/src/flow/flow.js +62 -41
- package/src/flow/for-each.js +12 -11
- package/src/flow/get-target-array.js +2 -2
- package/src/flow/load-node-module.js +1 -1
- package/src/flow/make-node-method.js +4 -4
- package/src/flow/resolve-pick-by-field.js +1 -1
- package/src/flow/run.js +200 -40
- package/src/flow/serialize.js +1 -1
- package/src/flow/spec-builder.js +1 -1
- package/src/flow/validate.js +1 -1
- package/src/nodes/butterworth-filter/init.js +5 -4
- package/src/nodes/categorize/update.js +3 -1
- package/src/nodes/controller/update.js +2 -1
- package/src/nodes/dwell-time-tracker/update.js +2 -1
- package/src/nodes/emit-if/helpers.js +57 -11
- package/src/nodes/emit-if/init.js +1 -1
- package/src/nodes/emit-if/update.js +8 -11
- package/src/nodes/page-hinkley/update.js +2 -1
- package/src/nodes/pass-if/update.js +3 -1
- package/src/nodes/persist-if/helpers.js +60 -4
- package/src/nodes/persist-if/init.js +1 -1
- package/src/nodes/persist-if/update.js +8 -16
- package/src/nodes/persistence-check/update.js +2 -1
- package/src/nodes/sanitize/update.js +4 -2
- package/src/nodes/swing-watch/update.js +3 -2
- package/src/nodes/threshold/update.js +2 -1
- package/src/nodes/trend/compute-confidence.js +4 -4
- package/src/nodes/trend/update.js +2 -1
- package/src/nodes/winnow/update.js +3 -1
- package/src/tools/training/features.js +13 -13
- package/src/tools/training/logistic.js +1 -1
- package/src/tools/training/metrics.js +2 -2
- package/src/tools/training/scale.js +2 -2
- package/src/tools/training/validate.js +7 -7
- package/src/core/utils/console-formatter/index.js +0 -207
- package/src/nodes/archive/csv/index.js +0 -6
- package/src/nodes/archive/csv/init.js +0 -36
- package/src/nodes/archive/csv/load.js +0 -70
- package/src/nodes/archive/csv/reset.js +0 -14
- package/src/nodes/archive/csv/simple-parser.js +0 -216
- package/src/nodes/archive/csv/test.js +0 -173
- package/src/nodes/archive/csv/update.js +0 -63
- package/src/nodes/sse-emitter/index.js +0 -7
- package/src/nodes/sse-emitter/init.js +0 -105
- package/src/nodes/sse-emitter/introspect.js +0 -134
- package/src/nodes/sse-emitter/publish-to.js +0 -6
- package/src/nodes/sse-emitter/recompute.js +0 -16
- package/src/nodes/sse-emitter/reset.js +0 -19
- package/src/nodes/sse-emitter/update.js +0 -69
- package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
- package/src/nodes/sse-emitter/utils/build-message.js +0 -31
- package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
- package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
- package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
- package/src/nodes/sse-emitter/utils/qos-handler.js +0 -41
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// core/utils/address/probe.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview The setup probe: does the configured address answer on
|
|
5
|
+
* every address it resolves to?
|
|
6
|
+
*
|
|
7
|
+
* Why it exists (ADR-030 item 4): a name such as `db.plant.local` can
|
|
8
|
+
* resolve to two addresses, `::1` and `127.0.0.1`, and a service may
|
|
9
|
+
* listen on only one. Node connects to the first address the resolver
|
|
10
|
+
* returns. On the run-5 rig the first address refused while the
|
|
11
|
+
* second answered, the flow ran on the second for 32 hours, and then
|
|
12
|
+
* the name stopped working with no change composer could see. So the
|
|
13
|
+
* QuestDB adapter probes both of its endpoints at setup, before any
|
|
14
|
+
* client is built, and fails setup unless every resolved address
|
|
15
|
+
* answers. The message names each address and its result and, when
|
|
16
|
+
* one answered, the literal to set.
|
|
17
|
+
*
|
|
18
|
+
* How it probes:
|
|
19
|
+
* - An IP literal is probed once. A name is resolved with Node's
|
|
20
|
+
* default lookup, all addresses, no family or order forced. That is
|
|
21
|
+
* the same resolve path the QuestDB client takes, so the probe sees
|
|
22
|
+
* what the client will see. A shell tool cannot stand in for it:
|
|
23
|
+
* `curl` and `nc` resolve and fall back differently from Node.
|
|
24
|
+
* - The lookup has the same time limit as a connect. A resolver pool
|
|
25
|
+
* that stalls is a real failure mode: on the run-5 rig one held
|
|
26
|
+
* every flush open for minutes. A lookup that does not answer in
|
|
27
|
+
* time fails the probe as `timeout`, and no address is dialled.
|
|
28
|
+
* - Every address is probed in resolver order, one at a time, with a
|
|
29
|
+
* time limit each. A probe is one TCP connect; the socket is
|
|
30
|
+
* destroyed the moment it answers, fails, or times out. The socket
|
|
31
|
+
* is not `unref`'d: an unref'd socket would let the process exit
|
|
32
|
+
* with the probe still pending when nothing else holds the event
|
|
33
|
+
* loop open, which is the case for a headless flow at setup. The
|
|
34
|
+
* time limit bounds the wait instead.
|
|
35
|
+
* - A late event after the first (an error after a connect) changes
|
|
36
|
+
* nothing; each attempt settles once.
|
|
37
|
+
* - Results are `answers`, `refused` (ECONNREFUSED), `timeout`
|
|
38
|
+
* (ETIMEDOUT or the time limit), or the Node error code as is.
|
|
39
|
+
*
|
|
40
|
+
* The lookup and the connect are injectable so the adapter specs run
|
|
41
|
+
* against scripted sockets. ADR-029's adapter calls the same probe
|
|
42
|
+
* once when a flush fails to connect, off the hot path, to attach a
|
|
43
|
+
* diagnosis to the report. Setup path only; nothing here runs per
|
|
44
|
+
* message.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import dns from 'node:dns';
|
|
48
|
+
import net from 'node:net';
|
|
49
|
+
|
|
50
|
+
import { formatHostPort, suggestLiteral } from './index.js';
|
|
51
|
+
|
|
52
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 2000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Names the outcome of a failed connect.
|
|
56
|
+
*
|
|
57
|
+
* @param {Error} err - The socket error
|
|
58
|
+
* @returns {string} `refused`, `timeout`, the Node code, or `error`
|
|
59
|
+
*/
|
|
60
|
+
const resultForError = function ( err ) {
|
|
61
|
+
if ( err.code === 'ECONNREFUSED' ) {
|
|
62
|
+
return 'refused';
|
|
63
|
+
}
|
|
64
|
+
if ( err.code === 'ETIMEDOUT' ) {
|
|
65
|
+
return 'timeout';
|
|
66
|
+
}
|
|
67
|
+
return err.code || 'error';
|
|
68
|
+
}; // resultForError()
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Builds the one-address dialler over the injected connect.
|
|
72
|
+
*
|
|
73
|
+
* @param {function} connectFn - `net.connect`-shaped: `( { host, port } ) => socket`
|
|
74
|
+
* @param {number} timeoutMs - The time limit per attempt
|
|
75
|
+
* @returns {function} `( address, port ) => Promise<string>` resolving to the result name
|
|
76
|
+
*/
|
|
77
|
+
const makeDial = function ( connectFn, timeoutMs ) {
|
|
78
|
+
return function ( address, port ) {
|
|
79
|
+
return new Promise( ( resolve ) => {
|
|
80
|
+
const socket = connectFn( { host: address, port } );
|
|
81
|
+
let settled = false;
|
|
82
|
+
const finish = function ( result ) {
|
|
83
|
+
if ( settled ) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
settled = true;
|
|
87
|
+
socket.destroy();
|
|
88
|
+
resolve( result );
|
|
89
|
+
};
|
|
90
|
+
socket.once( 'connect', () => finish( 'answers' ) );
|
|
91
|
+
socket.once( 'error', ( err ) => finish( resultForError( err ) ) );
|
|
92
|
+
socket.setTimeout( timeoutMs, () => finish( 'timeout' ) );
|
|
93
|
+
} );
|
|
94
|
+
};
|
|
95
|
+
}; // makeDial()
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolves a name within the time limit. The timer is cleared the
|
|
99
|
+
* moment the lookup settles, so a normal lookup leaves nothing behind.
|
|
100
|
+
* The timer is not `unref`'d, for the same reason as the socket above.
|
|
101
|
+
*
|
|
102
|
+
* @param {function} lookupFn - The resolver, `( host, { all: true } ) => Promise<targets>`
|
|
103
|
+
* @param {string} host - The name to resolve
|
|
104
|
+
* @param {number} timeoutMs - The time limit
|
|
105
|
+
* @returns {Promise<Array>} The resolved targets; rejects with code `timeout` at the limit
|
|
106
|
+
*/
|
|
107
|
+
const lookupWithin = function ( lookupFn, host, timeoutMs ) {
|
|
108
|
+
return new Promise( ( resolve, reject ) => {
|
|
109
|
+
const pending = lookupFn( host, { all: true } );
|
|
110
|
+
const timer = setTimeout( () => {
|
|
111
|
+
const err = new Error( `lookup of '${host}' did not answer within ${timeoutMs} ms` );
|
|
112
|
+
err.code = 'timeout';
|
|
113
|
+
reject( err );
|
|
114
|
+
}, timeoutMs );
|
|
115
|
+
pending.then(
|
|
116
|
+
( targets ) => {
|
|
117
|
+
clearTimeout( timer );
|
|
118
|
+
resolve( targets );
|
|
119
|
+
},
|
|
120
|
+
( err ) => {
|
|
121
|
+
clearTimeout( timer );
|
|
122
|
+
reject( err );
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
} );
|
|
126
|
+
}; // lookupWithin()
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Probes the targets one at a time, in order. Written as a recursion
|
|
130
|
+
* rather than a loop so no `await` sits inside a loop body; the depth
|
|
131
|
+
* is the number of resolved addresses, two or three in practice.
|
|
132
|
+
*
|
|
133
|
+
* @param {Array<{address: string, family: number}>} targets - Resolved addresses
|
|
134
|
+
* @param {number} port - The port to probe
|
|
135
|
+
* @param {function} dial - The dialler from `makeDial`
|
|
136
|
+
* @param {Array} attempts - The results so far
|
|
137
|
+
* @returns {Promise<Array>} One `{ address, family, result }` per target
|
|
138
|
+
*/
|
|
139
|
+
const probeEach = async function ( targets, port, dial, attempts ) {
|
|
140
|
+
if ( attempts.length === targets.length ) {
|
|
141
|
+
return attempts;
|
|
142
|
+
}
|
|
143
|
+
const target = targets[ attempts.length ];
|
|
144
|
+
const result = await dial( target.address, port );
|
|
145
|
+
attempts.push( { address: target.address, family: target.family, result } );
|
|
146
|
+
return probeEach( targets, port, dial, attempts );
|
|
147
|
+
}; // probeEach()
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Probes one classified address. Passes only when every resolved
|
|
151
|
+
* address answers.
|
|
152
|
+
*
|
|
153
|
+
* @param {Object} address - A `classifyAddress` result of kind `ip` or `name`, with a port
|
|
154
|
+
* @param {Object} [options] - Injection points
|
|
155
|
+
* @param {function} [options.lookupFn=dns.promises.lookup] - The resolver
|
|
156
|
+
* @param {function} [options.connectFn=net.connect] - The connect
|
|
157
|
+
* @param {number} [options.timeoutMs=2000] - The time limit per address, and for the lookup
|
|
158
|
+
* @returns {Promise<Object>} `{ ok, host, port, attempts }`, or
|
|
159
|
+
* `{ ok: false, host, port, lookupError, attempts: [] }` when the name did not resolve in time
|
|
160
|
+
*/
|
|
161
|
+
const probeAddress = async function ( address, options = {} ) {
|
|
162
|
+
const {
|
|
163
|
+
lookupFn = dns.promises.lookup,
|
|
164
|
+
connectFn = net.connect,
|
|
165
|
+
timeoutMs = DEFAULT_PROBE_TIMEOUT_MS
|
|
166
|
+
} = options;
|
|
167
|
+
const dial = makeDial( connectFn, timeoutMs );
|
|
168
|
+
|
|
169
|
+
let targets;
|
|
170
|
+
if ( address.kind === 'ip' ) {
|
|
171
|
+
targets = [ { address: address.host, family: address.family } ];
|
|
172
|
+
} else {
|
|
173
|
+
try {
|
|
174
|
+
targets = await lookupWithin( lookupFn, address.host, timeoutMs );
|
|
175
|
+
} catch ( err ) {
|
|
176
|
+
return { ok: false, host: address.host, port: address.port, lookupError: err.code || err.message, attempts: [] };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const attempts = await probeEach( targets, address.port, dial, [] );
|
|
181
|
+
const ok = attempts.every( ( attempt ) => attempt.result === 'answers' );
|
|
182
|
+
return { ok, host: address.host, port: address.port, attempts };
|
|
183
|
+
}; // probeAddress()
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Renders a probe outcome for an operator: each address with its
|
|
187
|
+
* result and, when one answered but another did not, the literal to
|
|
188
|
+
* set. The rig's own case reads
|
|
189
|
+
* `[::1]:9000 refused, 127.0.0.1:9000 answers; set ilpUrl to 127.0.0.1:9000`.
|
|
190
|
+
*
|
|
191
|
+
* @param {Object} outcome - A `probeAddress` result
|
|
192
|
+
* @param {string} field - The config key, for the suggestion
|
|
193
|
+
* @param {Object} address - The classified address that was probed
|
|
194
|
+
* @returns {string} The detail sentence
|
|
195
|
+
*/
|
|
196
|
+
const describeProbe = function ( outcome, field, address ) {
|
|
197
|
+
if ( outcome.lookupError !== undefined ) {
|
|
198
|
+
return `'${outcome.host}' did not resolve (${outcome.lookupError})`;
|
|
199
|
+
}
|
|
200
|
+
const listed = outcome.attempts
|
|
201
|
+
.map( ( attempt ) => `${formatHostPort( attempt.address, outcome.port )} ${attempt.result}` )
|
|
202
|
+
.join( ', ' );
|
|
203
|
+
const answering = outcome.attempts.find( ( attempt ) => attempt.result === 'answers' );
|
|
204
|
+
if ( outcome.ok || ( answering === undefined ) ) {
|
|
205
|
+
return listed;
|
|
206
|
+
}
|
|
207
|
+
return `${listed}; set ${field} to ${suggestLiteral( address, answering.address )}`;
|
|
208
|
+
}; // describeProbe()
|
|
209
|
+
|
|
210
|
+
export { probeAddress, describeProbe, DEFAULT_PROBE_TIMEOUT_MS };
|