@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7

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 (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,397 @@
1
+ /**
2
+ * STUN (RFC 5389) — reflexive address discovery for the W2M P2P transport.
3
+ *
4
+ * Why this exists: the relay is a star. Every task offer and every result crosses
5
+ * it, which is what makes the design work through NAT without opening a port —
6
+ * and also what makes the relay a bandwidth bottleneck and a single point of
7
+ * failure. A direct path needs each side to learn the *public* address the other
8
+ * can reach it on, and that is the one thing a host cannot work out alone.
9
+ *
10
+ * Scope and honesty:
11
+ *
12
+ * * **IPv4 only.** The deployment material targets an IPv4 relay; IPv6 needs its
13
+ * own socket family and is deliberately not half-implemented here.
14
+ * * **No third-party dependency.** The project's rule is `node:` builtins only,
15
+ * so this is a from-scratch implementation of the two messages actually
16
+ * needed: Binding Request and Binding Success Response. No authentication
17
+ * attributes (MESSAGE-INTEGRITY) — a public STUN server needs none, and
18
+ * implementing them badly would be worse than not offering them.
19
+ * * **It reports evidence, not a verdict.** NAT type is inferred from whether
20
+ * several servers agree on the mapped port, which distinguishes
21
+ * endpoint-independent mapping (hole punching works) from endpoint-dependent
22
+ * mapping (it usually does not). That inference is stated with the
23
+ * observations behind it, because "symmetric NAT" is a conclusion a caller
24
+ * must be able to check.
25
+ */
26
+
27
+ import dgram from 'node:dgram';
28
+ import { randomBytes } from 'node:crypto';
29
+
30
+ /** RFC 5389 §6: the fixed magic cookie that makes STUN demultiplexable. */
31
+ export const STUN_MAGIC_COOKIE = 0x2112a442;
32
+
33
+ export const STUN_BINDING_REQUEST = 0x0001;
34
+ export const STUN_BINDING_SUCCESS = 0x0101;
35
+
36
+ const ATTR_MAPPED_ADDRESS = 0x0001;
37
+ const ATTR_XOR_MAPPED_ADDRESS = 0x0020;
38
+ const ATTR_ERROR_CODE = 0x0009;
39
+
40
+ /**
41
+ * Public STUN servers used by default.
42
+ *
43
+ * Two different operators on purpose: the NAT mapping can only be classified by
44
+ * comparing what *different* destinations see, and two hostnames that resolve to
45
+ * the same anycast address would defeat that.
46
+ */
47
+ export const DEFAULT_STUN_SERVERS = [
48
+ 'stun.cloudflare.com:3478',
49
+ 'stun.l.google.com:19302',
50
+ 'stun.nextcloud.com:443',
51
+ ];
52
+
53
+ /** Default per-query timeout. Public STUN answers in tens of milliseconds; 2 s is generous. */
54
+ export const DEFAULT_STUN_TIMEOUT_MS = 2000;
55
+
56
+ // ---------------------------------------------------------------------------------------------
57
+ // Wire format
58
+ // ---------------------------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Build a Binding Request.
62
+ *
63
+ * @param {Buffer} [transactionId] 12 bytes; generated when omitted.
64
+ * @returns {{bytes: Buffer, transactionId: Buffer}}
65
+ */
66
+ export function encodeBindingRequest(transactionId = randomBytes(12)) {
67
+ if (!Buffer.isBuffer(transactionId) || transactionId.length !== 12) {
68
+ throw new TypeError('STUN transaction id must be a 12-byte Buffer');
69
+ }
70
+ const header = Buffer.alloc(20);
71
+ header.writeUInt16BE(STUN_BINDING_REQUEST, 0);
72
+ header.writeUInt16BE(0, 2); // no attributes
73
+ header.writeUInt32BE(STUN_MAGIC_COOKIE, 4);
74
+ transactionId.copy(header, 8);
75
+ return { bytes: header, transactionId };
76
+ }
77
+
78
+ /**
79
+ * Parse the attributes of one STUN message.
80
+ *
81
+ * @param {Buffer} buf
82
+ * @returns {{type:number, transactionId:Buffer, attributes:Array<{type:number, value:Buffer}>,
83
+ * mappedAddress:{address:string,port:number,family:number}|null,
84
+ * errorCode:{code:number,reason:string}|null}|null}
85
+ * `null` when the buffer is not a STUN message at all.
86
+ */
87
+ export function decodeMessage(buf) {
88
+ if (!Buffer.isBuffer(buf) || buf.length < 20) return null;
89
+ if (buf.readUInt32BE(4) !== STUN_MAGIC_COOKIE) return null;
90
+ // RFC 5389 §6: the two most significant bits must be zero.
91
+ if ((buf[0] & 0xc0) !== 0) return null;
92
+
93
+ const type = buf.readUInt16BE(0);
94
+ const length = buf.readUInt16BE(2);
95
+ const transactionId = buf.subarray(8, 20);
96
+ const end = Math.min(20 + length, buf.length);
97
+
98
+ const attributes = [];
99
+ let offset = 20;
100
+ while (offset + 4 <= end) {
101
+ const attrType = buf.readUInt16BE(offset);
102
+ const attrLen = buf.readUInt16BE(offset + 2);
103
+ const value = buf.subarray(offset + 4, Math.min(offset + 4 + attrLen, end));
104
+ attributes.push({ type: attrType, value });
105
+ // Attributes are padded to a 4-byte boundary.
106
+ offset += 4 + attrLen + ((4 - (attrLen % 4)) % 4);
107
+ }
108
+
109
+ let mappedAddress = null;
110
+ let errorCode = null;
111
+ for (const attr of attributes) {
112
+ if (attr.type === ATTR_XOR_MAPPED_ADDRESS) {
113
+ mappedAddress = decodeAddress(attr.value, transactionId, true);
114
+ } else if (attr.type === ATTR_MAPPED_ADDRESS && mappedAddress === null) {
115
+ // Only a fallback: the XOR form is the one RFC 5389 defines, the plain form is
116
+ // RFC 3489 legacy that some servers still send.
117
+ mappedAddress = decodeAddress(attr.value, transactionId, false);
118
+ } else if (attr.type === ATTR_ERROR_CODE && attr.value.length >= 4) {
119
+ errorCode = {
120
+ code: (attr.value[2] & 0x07) * 100 + attr.value[3],
121
+ reason: attr.value.subarray(4).toString('utf8'),
122
+ };
123
+ }
124
+ }
125
+
126
+ return { type, transactionId, attributes, mappedAddress, errorCode };
127
+ }
128
+
129
+ /**
130
+ * Decode MAPPED-ADDRESS / XOR-MAPPED-ADDRESS.
131
+ *
132
+ * @param {Buffer} value
133
+ * @param {Buffer} transactionId
134
+ * @param {boolean} xor
135
+ * @returns {{address:string, port:number, family:number}|null}
136
+ */
137
+ function decodeAddress(value, transactionId, xor) {
138
+ if (value.length < 4) return null;
139
+ // RFC 5389 §15.2 / §15.1: the first byte is reserved (x) and the SECOND byte is
140
+ // Family. Reading value[0] as the family is the obvious mistake, and it fails
141
+ // silently -- every address decodes to `null`, which is indistinguishable from a
142
+ // server that sent no address at all. Caught by querying a real STUN server after
143
+ // a self-written fake server had happily agreed with the wrong offset.
144
+ const family = value[1];
145
+ let port = value.readUInt16BE(2);
146
+ let address;
147
+ if (family === 0x01) {
148
+ if (value.length < 8) return null;
149
+ const raw = Buffer.from(value.subarray(4, 8));
150
+ if (xor) {
151
+ const mask = Buffer.alloc(4);
152
+ mask.writeUInt32BE(STUN_MAGIC_COOKIE, 0);
153
+ for (let i = 0; i < 4; i += 1) raw[i] ^= mask[i];
154
+ }
155
+ address = `${raw[0]}.${raw[1]}.${raw[2]}.${raw[3]}`;
156
+ } else if (family === 0x02) {
157
+ if (value.length < 20) return null;
158
+ const raw = Buffer.from(value.subarray(4, 20));
159
+ if (xor) {
160
+ const mask = Buffer.concat([Buffer.from([0x21, 0x12, 0xa4, 0x42]), transactionId]);
161
+ for (let i = 0; i < 16; i += 1) raw[i] ^= mask[i];
162
+ }
163
+ const groups = [];
164
+ for (let i = 0; i < 16; i += 2) groups.push(raw.readUInt16BE(i).toString(16));
165
+ address = groups.join(':');
166
+ } else {
167
+ return null;
168
+ }
169
+
170
+ if (xor) port ^= STUN_MAGIC_COOKIE >>> 16;
171
+ return { address, port, family };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------------------------
175
+ // Socket
176
+ // ---------------------------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Bind a UDP socket on an ephemeral port.
180
+ *
181
+ * The socket is returned *unclosed on purpose*: its source port is what STUN
182
+ * reports, and the whole point of querying STUN first is that the mapping stays
183
+ * open for the hole punch that follows. Closing it would discard the mapping the
184
+ * NAT just created.
185
+ *
186
+ * @param {{address?:string, port?:number}} [options]
187
+ * @returns {Promise<{socket:import('node:dgram').Socket, local:{address:string,port:number}}>}
188
+ */
189
+ export function bindUdpSocket(options = {}) {
190
+ const address = options.address ?? '0.0.0.0';
191
+ const port = options.port ?? 0;
192
+ return new Promise((resolve, reject) => {
193
+ const socket = dgram.createSocket('udp4');
194
+ const onError = (error) => {
195
+ socket.removeListener('listening', onListening);
196
+ try {
197
+ socket.close();
198
+ } catch {
199
+ /* already closed */
200
+ }
201
+ reject(error);
202
+ };
203
+ const onListening = () => {
204
+ socket.removeListener('error', onError);
205
+ const bound = socket.address();
206
+ resolve({ socket, local: { address: bound.address, port: bound.port } });
207
+ };
208
+ socket.once('error', onError);
209
+ socket.once('listening', onListening);
210
+ socket.bind(port, address);
211
+ });
212
+ }
213
+
214
+ /** `"host:port"` → `{host, port}`. Rejects anything that is not exactly that. */
215
+ export function parseServer(spec) {
216
+ const text = String(spec).trim();
217
+ const idx = text.lastIndexOf(':');
218
+ if (idx <= 0) throw new TypeError(`STUN server must be "host:port", got ${JSON.stringify(spec)}`);
219
+ const host = text.slice(0, idx);
220
+ const port = Number(text.slice(idx + 1));
221
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
222
+ throw new TypeError(`STUN server port out of range in ${JSON.stringify(spec)}`);
223
+ }
224
+ return { host, port, spec: text };
225
+ }
226
+
227
+ /**
228
+ * Send one Binding Request and wait for the matching response.
229
+ *
230
+ * @param {import('node:dgram').Socket} socket
231
+ * @param {string} server `"host:port"`
232
+ * @param {{timeoutMs?:number, transactionId?:Buffer}} [options]
233
+ * @returns {Promise<{ok:boolean, server:string, reflexive:{address:string,port:number}|null,
234
+ * rttMs:number|null, error:string|null}>}
235
+ */
236
+ export function stunQuery(socket, server, options = {}) {
237
+ const timeoutMs = options.timeoutMs ?? DEFAULT_STUN_TIMEOUT_MS;
238
+ const { host, port, spec } = parseServer(server);
239
+ const { bytes, transactionId } = encodeBindingRequest(options.transactionId);
240
+
241
+ return new Promise((resolve) => {
242
+ let settled = false;
243
+ const startedAt = process.hrtime.bigint();
244
+
245
+ const finish = (result) => {
246
+ if (settled) return;
247
+ settled = true;
248
+ clearTimeout(timer);
249
+ socket.removeListener('message', onMessage);
250
+ resolve(result);
251
+ };
252
+
253
+ const onMessage = (message) => {
254
+ const decoded = decodeMessage(message);
255
+ if (!decoded) return; // not STUN; some other service answered on that port
256
+ if (!decoded.transactionId.equals(transactionId)) return; // not our transaction
257
+ if (decoded.type !== STUN_BINDING_SUCCESS) {
258
+ finish({
259
+ ok: false,
260
+ server: spec,
261
+ reflexive: null,
262
+ rttMs: null,
263
+ error: decoded.errorCode
264
+ ? `STUN_ERROR_${decoded.errorCode.code}: ${decoded.errorCode.reason}`
265
+ : `STUN_UNEXPECTED_TYPE_0x${decoded.type.toString(16)}`,
266
+ });
267
+ return;
268
+ }
269
+ if (!decoded.mappedAddress) {
270
+ finish({ ok: false, server: spec, reflexive: null, rttMs: null, error: 'STUN_NO_MAPPED_ADDRESS' });
271
+ return;
272
+ }
273
+ const rttMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
274
+ finish({
275
+ ok: true,
276
+ server: spec,
277
+ reflexive: { address: decoded.mappedAddress.address, port: decoded.mappedAddress.port },
278
+ rttMs: Math.round(rttMs * 100) / 100,
279
+ error: null,
280
+ });
281
+ };
282
+
283
+ const timer = setTimeout(() => {
284
+ finish({ ok: false, server: spec, reflexive: null, rttMs: null, error: 'STUN_TIMEOUT' });
285
+ }, timeoutMs);
286
+
287
+ socket.on('message', onMessage);
288
+ socket.send(bytes, port, host, (error) => {
289
+ if (error) {
290
+ finish({ ok: false, server: spec, reflexive: null, rttMs: null, error: `STUN_SEND_${error.code ?? 'FAILED'}` });
291
+ }
292
+ });
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Query several servers and classify the NAT behaviour from what they agree on.
298
+ *
299
+ * The classification that matters for hole punching is **mapping behaviour**:
300
+ *
301
+ * * every server reports the same public port → endpoint-independent mapping
302
+ * ("cone"), which is what makes a punched hole reusable;
303
+ * * different ports per destination → endpoint-dependent mapping ("symmetric"),
304
+ * where the port a peer was told about is not the port the peer will see.
305
+ *
306
+ * It is deliberately *not* called a full NAT-type detection: filtering behaviour
307
+ * (address/port-restricted cone) would need a second host to probe from, and
308
+ * claiming to detect what was not probed is exactly the kind of unverified
309
+ * certainty this project refuses elsewhere.
310
+ *
311
+ * @param {object} options
312
+ * @param {import('node:dgram').Socket} options.socket
313
+ * @param {string[]} [options.servers]
314
+ * @param {number} [options.timeoutMs]
315
+ * @returns {Promise<{ok:boolean, reflexive:{address:string,port:number}|null,
316
+ * mapping:'none'|'endpoint-independent'|'endpoint-dependent'|'unknown',
317
+ * local:{address:string,port:number}, servers:Array<object>, error:string|null}>}
318
+ */
319
+ export async function discoverReflexive(options = {}) {
320
+ const { socket } = options;
321
+ if (!socket) throw new TypeError('discoverReflexive: a bound UDP socket is required');
322
+ const servers = options.servers?.length ? options.servers : DEFAULT_STUN_SERVERS;
323
+ const local = socket.address();
324
+
325
+ // Sequential, not parallel: the mapping comparison is only meaningful if each
326
+ // observation is attributable to one destination, and a burst of identical
327
+ // requests from one socket to three servers invites a NAT to coalesce them.
328
+ const results = [];
329
+ for (const server of servers) {
330
+ // A server we cannot even parse is a configuration error, not a probe result.
331
+ try {
332
+ parseServer(server);
333
+ } catch (error) {
334
+ results.push({ ok: false, server, reflexive: null, rttMs: null, error: error.message });
335
+ continue;
336
+ }
337
+ results.push(await stunQuery(socket, server, { timeoutMs: options.timeoutMs }));
338
+ }
339
+
340
+ const answered = results.filter((r) => r.ok);
341
+ if (answered.length === 0) {
342
+ return {
343
+ ok: false,
344
+ reflexive: null,
345
+ mapping: 'unknown',
346
+ local: { address: local.address, port: local.port },
347
+ servers: results,
348
+ error: results.every((r) => r.error === 'STUN_TIMEOUT' || r.error?.startsWith('STUN_SEND_'))
349
+ ? 'STUN_UNREACHABLE: no configured STUN server answered'
350
+ : 'STUN_NO_ANSWER',
351
+ };
352
+ }
353
+
354
+ const ports = new Set(answered.map((r) => r.reflexive.port));
355
+ const addresses = new Set(answered.map((r) => r.reflexive.address));
356
+ const first = answered[0].reflexive;
357
+
358
+ let mapping;
359
+ if (addresses.size === 1 && addresses.has(local.address) && ports.has(local.port)) {
360
+ // The server saw the address the socket is already bound to: nothing is translating.
361
+ // This one IS decidable from a single answer -- the server reported our own socket
362
+ // address, which no translator would do. (Bound to 0.0.0.0 the local address is
363
+ // "0.0.0.0", so this cannot fire by accident behind a NAT.)
364
+ mapping = 'none';
365
+ } else if (answered.length < 2) {
366
+ // Ordering matters: `ports.size === 1` is trivially true for a single answer, so
367
+ // checking it first makes every one-server probe look endpoint-independent. Saying
368
+ // either mapping from one observation would be a guess, so we say neither.
369
+ mapping = 'unknown';
370
+ } else if (ports.size === 1) {
371
+ mapping = 'endpoint-independent';
372
+ } else {
373
+ mapping = 'endpoint-dependent';
374
+ }
375
+
376
+ return {
377
+ ok: true,
378
+ reflexive: { address: first.address, port: first.port },
379
+ mapping,
380
+ local: { address: local.address, port: local.port },
381
+ servers: results,
382
+ error: null,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Whether a mapping behaviour can plausibly be punched.
388
+ *
389
+ * `unknown` returns `true` on purpose: one unreachable STUN server must not be
390
+ * the reason a direct path is never even attempted. The punch itself is the test.
391
+ *
392
+ * @param {string} mapping
393
+ * @returns {boolean}
394
+ */
395
+ export function mappingSupportsPunching(mapping) {
396
+ return mapping !== 'endpoint-dependent';
397
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Base-address handling for W2M clients (v0.1.2 §2).
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ *
6
+ * The v1 client built every endpoint with the two-argument URL constructor:
7
+ *
8
+ * new URL('/v1/stream', rabbitUrl)
9
+ *
10
+ * That call treats the first argument as an absolute path, so it *discards*
11
+ * whatever path `rabbitUrl` had:
12
+ *
13
+ * new URL('/v1/stream', 'https://h/w2m') -> 'https://h/v1/stream'
14
+ * ^^^^ /w2m gone
15
+ *
16
+ * Every request 404s as soon as the relay sits behind a sub-path, which is the
17
+ * normal situation for a Cloudflare Tunnel / ngrok / nginx deployment. The same
18
+ * trap applies to `path.posix.join('https://h', '/v1')`, which collapses the
19
+ * `//` in the scheme into a single slash.
20
+ *
21
+ * The whole fix is therefore: normalise the base once, then concatenate. A
22
+ * base is an opaque prefix, not something to be re-parsed per request.
23
+ *
24
+ * `rabbitUrl` rules (v0.1.2 §2):
25
+ * * must parse as a URL and use http/https;
26
+ * * must NOT carry a query string or a fragment (a prefix belongs in the
27
+ * path: `https://host/w2m`, not `https://host?prefix=/w2m`);
28
+ * * trailing slashes are stripped, so `https://host/w2m/` and
29
+ * `https://host/w2m` are the same base;
30
+ * * a sub-path is preserved verbatim.
31
+ */
32
+
33
+ /** Error code attached to every rejection from this module. */
34
+ export const RABBIT_URL_INVALID = 'RABBIT_URL_INVALID';
35
+
36
+ /**
37
+ * Build a rejection that always names `rabbitUrl` and the reason.
38
+ *
39
+ * @param {string} message
40
+ * @returns {TypeError}
41
+ */
42
+ function invalid(message) {
43
+ const error = new TypeError(message);
44
+ error.code = RABBIT_URL_INVALID;
45
+ return error;
46
+ }
47
+
48
+ /**
49
+ * Validate and normalise a `rabbitUrl` into a base address.
50
+ *
51
+ * @param {string} rabbitUrl e.g. `https://w2m.example.com/w2m/`
52
+ * @returns {string} Base without any trailing slash, e.g. `https://w2m.example.com/w2m`
53
+ * @throws {TypeError} with `code === 'RABBIT_URL_INVALID'` when unusable.
54
+ */
55
+ export function resolveBaseUrl(rabbitUrl) {
56
+ if (typeof rabbitUrl !== 'string' || rabbitUrl.trim() === '') {
57
+ throw invalid(
58
+ 'rabbitUrl is required: pass a base address such as http://127.0.0.1:8787 or https://w2m.example.com/w2m',
59
+ );
60
+ }
61
+ const raw = rabbitUrl.trim();
62
+
63
+ // Single-argument parse: this is URL *validation*, not path resolution.
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(raw);
67
+ } catch {
68
+ throw invalid(`rabbitUrl is not a valid URL: ${JSON.stringify(raw)}`);
69
+ }
70
+
71
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
72
+ throw invalid(
73
+ `rabbitUrl must use http or https, got ${parsed.protocol.replace(':', '')}:// in ${JSON.stringify(raw)}`,
74
+ );
75
+ }
76
+
77
+ // A literal `?`/`#` anywhere in a base address is a mistake, including the
78
+ // degenerate bare `https://host/path?` that `parsed.search` reports as empty.
79
+ if (parsed.search !== '' || raw.includes('?')) {
80
+ throw invalid(
81
+ `rabbitUrl must not contain a query string: ${JSON.stringify(raw)}; put the deployment prefix in the path instead, e.g. https://host/w2m`,
82
+ );
83
+ }
84
+ if (parsed.hash !== '' || raw.includes('#')) {
85
+ throw invalid(
86
+ `rabbitUrl must not contain a fragment: ${JSON.stringify(raw)}; put the deployment prefix in the path instead, e.g. https://host/w2m`,
87
+ );
88
+ }
89
+
90
+ // Strip every trailing slash. `new URL('http://h')` yields pathname '/', so
91
+ // this also collapses a bare origin back to `http://h`.
92
+ const path = parsed.pathname.replace(/\/+$/, '');
93
+ return `${parsed.protocol}//${parsed.host}${path}`;
94
+ }
95
+
96
+ /**
97
+ * Join a normalised base with an endpoint path by plain concatenation.
98
+ *
99
+ * The base is never re-parsed, so a sub-path survives; the path is never
100
+ * re-parsed either, so `//` in the scheme survives.
101
+ *
102
+ * @param {string} base Result of {@link resolveBaseUrl}.
103
+ * @param {string} path Endpoint path, with or without a leading slash.
104
+ * @returns {string}
105
+ */
106
+ export function joinUrl(base, path) {
107
+ if (typeof base !== 'string' || base === '') {
108
+ throw new TypeError('joinUrl: base must be a non-empty string (use resolveBaseUrl first)');
109
+ }
110
+ // Tolerate a base that still carries a trailing slash, so this helper cannot
111
+ // produce `//v1/...` for a caller that skipped normalisation.
112
+ const cleanBase = base.replace(/\/+$/, '');
113
+ const suffix = path === null || path === undefined ? '' : String(path);
114
+ if (suffix === '') return cleanBase;
115
+ return cleanBase + (suffix.startsWith('/') ? suffix : `/${suffix}`);
116
+ }
117
+
118
+ /**
119
+ * Convenience: normalise a base and build one endpoint URL from it.
120
+ *
121
+ * @param {string} rabbitUrl
122
+ * @param {string} path
123
+ * @returns {string}
124
+ */
125
+ export function endpointUrl(rabbitUrl, path) {
126
+ return joinUrl(resolveBaseUrl(rabbitUrl), path);
127
+ }