abb-rws-client 0.7.2 → 1.0.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 (70) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +17 -8
  3. package/dist/HalJsonParser.d.ts +55 -0
  4. package/dist/HalJsonParser.d.ts.map +1 -0
  5. package/dist/HalJsonParser.js +155 -0
  6. package/dist/HalJsonParser.js.map +1 -0
  7. package/dist/HttpSession.d.ts.map +1 -1
  8. package/dist/HttpSession.js +13 -2
  9. package/dist/HttpSession.js.map +1 -1
  10. package/dist/IRWSAdapter.d.ts +7 -1
  11. package/dist/IRWSAdapter.d.ts.map +1 -1
  12. package/dist/MdnsDiscovery.d.ts +57 -0
  13. package/dist/MdnsDiscovery.d.ts.map +1 -0
  14. package/dist/MdnsDiscovery.js +313 -0
  15. package/dist/MdnsDiscovery.js.map +1 -0
  16. package/dist/MultiRobotManager.d.ts +5 -2
  17. package/dist/MultiRobotManager.d.ts.map +1 -1
  18. package/dist/MultiRobotManager.js +8 -3
  19. package/dist/MultiRobotManager.js.map +1 -1
  20. package/dist/RWS1Adapter.d.ts +60 -2
  21. package/dist/RWS1Adapter.d.ts.map +1 -1
  22. package/dist/RWS1Adapter.js +152 -4
  23. package/dist/RWS1Adapter.js.map +1 -1
  24. package/dist/ResourceMapper.d.ts +4 -0
  25. package/dist/ResourceMapper.d.ts.map +1 -1
  26. package/dist/ResourceMapper.js +9 -1
  27. package/dist/ResourceMapper.js.map +1 -1
  28. package/dist/RobotManager.d.ts +72 -12
  29. package/dist/RobotManager.d.ts.map +1 -1
  30. package/dist/RobotManager.js +255 -50
  31. package/dist/RobotManager.js.map +1 -1
  32. package/dist/RwsClient2.d.ts +150 -10
  33. package/dist/RwsClient2.d.ts.map +1 -1
  34. package/dist/RwsClient2.js +599 -236
  35. package/dist/RwsClient2.js.map +1 -1
  36. package/dist/WsSubscriber.d.ts +31 -5
  37. package/dist/WsSubscriber.d.ts.map +1 -1
  38. package/dist/WsSubscriber.js +104 -50
  39. package/dist/WsSubscriber.js.map +1 -1
  40. package/dist/detect.d.ts +12 -3
  41. package/dist/detect.d.ts.map +1 -1
  42. package/dist/detect.js +69 -25
  43. package/dist/detect.js.map +1 -1
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/types.js +3 -3
  49. package/dist/types.js.map +1 -1
  50. package/examples/05-remote-control-rmmp.mjs +10 -12
  51. package/examples/06-pull-module-source.mjs +13 -20
  52. package/package.json +62 -60
  53. package/src/HalJsonParser.ts +137 -0
  54. package/src/HttpSession.ts +460 -0
  55. package/src/IRWSAdapter.ts +422 -0
  56. package/src/Logger.ts +54 -0
  57. package/src/MdnsDiscovery.ts +336 -0
  58. package/src/MultiRobotManager.ts +159 -0
  59. package/src/RWS1Adapter.ts +1018 -0
  60. package/src/RWS2Adapter.ts +19 -0
  61. package/src/ResourceMapper.ts +517 -0
  62. package/src/ResponseParser.ts +710 -0
  63. package/src/RobotManager.ts +1705 -0
  64. package/src/RwsClient.ts +1150 -0
  65. package/src/RwsClient2.ts +2214 -0
  66. package/src/WsSubscriber.ts +350 -0
  67. package/src/XhtmlParser.ts +53 -0
  68. package/src/detect.ts +261 -0
  69. package/src/index.ts +83 -0
  70. package/src/types.ts +336 -0
@@ -0,0 +1,460 @@
1
+ /**
2
+ * HttpSession — HTTP communication layer for ABB IRC5 controllers.
3
+ *
4
+ * Features:
5
+ * - HTTP Digest Authentication (RFC 2617) implemented from scratch using node:crypto
6
+ * - ABBCX + -http-session- cookie management
7
+ * - Request queue enforcing minimum interval between requests (<20 req/sec RWS limit)
8
+ * - Automatic re-authentication on session expiry (5-minute inactivity)
9
+ * - 401 retry with fresh digest handshake; 503 retry with 200ms backoff
10
+ * - AbortController-based timeout
11
+ *
12
+ * Uses Node 18+ built-in fetch and node:crypto. Zero external dependencies.
13
+ */
14
+
15
+ import { createHash, randomBytes } from 'node:crypto';
16
+ import { RwsError } from './types.js';
17
+ import { Logger } from './Logger.js';
18
+ import type { DigestChallenge, HttpResponse } from './types.js';
19
+
20
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
21
+
22
+ function md5(s: string): string {
23
+ return createHash('md5').update(s).digest('hex');
24
+ }
25
+
26
+ function sleep(ms: number): Promise<void> {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
29
+
30
+ // ─── HttpSession options ─────────────────────────────────────────────────────
31
+
32
+ export interface HttpSessionOptions {
33
+ baseUrl: string;
34
+ username: string;
35
+ password: string;
36
+ requestIntervalMs: number;
37
+ timeoutMs: number;
38
+ /** Pre-load a saved -http-session- cookie to reuse an existing controller session slot */
39
+ sessionCookie?: string;
40
+ }
41
+
42
+ // ─── HttpSession ─────────────────────────────────────────────────────────────
43
+
44
+ export class HttpSession {
45
+ private readonly baseUrl: string;
46
+ private readonly username: string;
47
+ private readonly password: string;
48
+ private readonly requestIntervalMs: number;
49
+ private readonly timeoutMs: number;
50
+
51
+ /** Stored session cookies: '-http-session-' and 'ABBCX' */
52
+ private cookies: Map<string, string> = new Map();
53
+
54
+ /** Last parsed digest challenge from WWW-Authenticate */
55
+ private digestChallenge: DigestChallenge | null = null;
56
+
57
+ /** Nonce use counter — reset to 0 whenever a new nonce is received */
58
+ private nonceCount = 0;
59
+
60
+ /** Timestamp of the most recent request sent */
61
+ private lastRequestTime = 0;
62
+
63
+ /** Timestamp of the most recent successful response — used for session expiry */
64
+ private lastActivityTime = 0;
65
+
66
+ /** Promise chain that serialises all outbound requests */
67
+ private requestQueue: Promise<void> = Promise.resolve();
68
+
69
+ /** 5-minute session inactivity timeout (milliseconds) */
70
+ private static readonly SESSION_TIMEOUT_MS = 5 * 60 * 1000;
71
+
72
+ constructor(options: HttpSessionOptions) {
73
+ this.baseUrl = options.baseUrl;
74
+ this.username = options.username;
75
+ this.password = options.password;
76
+ this.requestIntervalMs = options.requestIntervalMs;
77
+ this.timeoutMs = options.timeoutMs;
78
+ // Pre-load saved cookies so reconnects reuse the same session slot.
79
+ // Accepts the full Cookie header string, e.g. "-http-session-=...; ABBCX=..."
80
+ if (options.sessionCookie) {
81
+ for (const part of options.sessionCookie.split(';')) {
82
+ const eq = part.indexOf('=');
83
+ if (eq === -1) continue;
84
+ const name = part.slice(0, eq).trim();
85
+ const value = part.slice(eq + 1).trim();
86
+ if (name) this.cookies.set(name, value);
87
+ }
88
+ }
89
+ }
90
+
91
+ // ─── Public HTTP methods ────────────────────────────────────────────────────
92
+
93
+ get(path: string): Promise<HttpResponse> {
94
+ return this.enqueue(() => this.execute('GET', path));
95
+ }
96
+
97
+ post(path: string, body?: string): Promise<HttpResponse> {
98
+ return this.enqueue(() => this.execute('POST', path, body));
99
+ }
100
+
101
+ put(path: string, body: string | Uint8Array): Promise<HttpResponse> {
102
+ return this.enqueue(() => this.execute('PUT', path, body));
103
+ }
104
+
105
+ delete(path: string): Promise<HttpResponse> {
106
+ return this.enqueue(() => this.execute('DELETE', path));
107
+ }
108
+
109
+ /** Returns the current cookie string for use in WebSocket connections */
110
+ getCookieHeader(): string {
111
+ return this.buildCookieHeader();
112
+ }
113
+
114
+ /** Returns the full cookie header string (all cookies) for persistence across reloads */
115
+ getSessionCookie(): string | null {
116
+ const header = this.buildCookieHeader();
117
+ return header || null;
118
+ }
119
+
120
+ /**
121
+ * Called on disconnect. Intentionally preserves all session state (cookie,
122
+ * digest challenge, nonce) so the next connect() reuses the same controller
123
+ * session slot without triggering a new 401 handshake.
124
+ *
125
+ * The controller limits concurrent sessions (70 max on IRC5). Creating a new
126
+ * session on every reconnect fills the pool and causes persistent 503 errors.
127
+ * If the nonce has gone stale the controller returns 401 and we re-auth inline.
128
+ */
129
+ clearSession(): void {
130
+ // No-op: preserve cookie + digest state across disconnect/reconnect cycles.
131
+ }
132
+
133
+ // ─── Request queue ──────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Append a function to the serial request queue, enforcing the minimum interval
137
+ * between requests. Error suppression on `this.requestQueue` (not on `result`)
138
+ * ensures queue continues processing even when individual requests fail.
139
+ */
140
+ private enqueue<T>(fn: () => Promise<T>): Promise<T> {
141
+ const result = this.requestQueue.then(async () => {
142
+ const elapsed = Date.now() - this.lastRequestTime;
143
+ if (this.requestIntervalMs > 0 && elapsed < this.requestIntervalMs) {
144
+ await sleep(this.requestIntervalMs - elapsed);
145
+ }
146
+ this.lastRequestTime = Date.now();
147
+ return fn();
148
+ });
149
+
150
+ // Detach error from the shared queue chain so a failed request does not block
151
+ // subsequent ones. Callers still receive the rejection via `result`.
152
+ this.requestQueue = result.then(
153
+ () => undefined,
154
+ () => undefined,
155
+ );
156
+
157
+ return result;
158
+ }
159
+
160
+ // ─── Core request execution ─────────────────────────────────────────────────
161
+
162
+ private async execute(
163
+ method: string,
164
+ path: string,
165
+ body?: string | Uint8Array,
166
+ ): Promise<HttpResponse> {
167
+ const startedAt = Date.now();
168
+ const bodyPreview = body
169
+ ? (typeof body === 'string' ? body : Buffer.from(body).toString('utf8')).slice(0, 200)
170
+ : undefined;
171
+ Logger.trace?.('http.req', `RWS1 ${method} ${path}`, { protocol: 'rws1', method, path, bodyPreview });
172
+
173
+ // Auto re-authenticate if the session may have expired.
174
+ // Only clear digest auth state — keep the session cookie so the controller
175
+ // reuses the same session slot instead of creating a new one.
176
+ if (this.isSessionExpired()) {
177
+ this.digestChallenge = null;
178
+ this.nonceCount = 0;
179
+ }
180
+
181
+ let response = await this.rawFetch(method, path, body);
182
+
183
+ // 401 → perform digest handshake then retry once
184
+ if (response.status === 401) {
185
+ const wwwAuth = response.headers.get('www-authenticate');
186
+ if (!wwwAuth) {
187
+ Logger.trace?.('http.err', `RWS1 ${method} ${path} → 401 (no auth header)`, { protocol: 'rws1', method, path });
188
+ throw new RwsError('401 without WWW-Authenticate header', 'AUTH_FAILED', 401);
189
+ }
190
+ this.digestChallenge = this.parseDigestChallenge(wwwAuth);
191
+ this.nonceCount = 0;
192
+
193
+ response = await this.rawFetch(method, path, body);
194
+
195
+ if (response.status === 401) {
196
+ Logger.trace?.('http.err', `RWS1 ${method} ${path} → 401 (auth failed)`, { protocol: 'rws1', method, path });
197
+ throw new RwsError('Authentication failed — check username and password', 'AUTH_FAILED', 401);
198
+ }
199
+ }
200
+
201
+ // 503 → wait 200ms and retry once
202
+ if (response.status === 503) {
203
+ await sleep(200);
204
+ response = await this.rawFetch(method, path, body);
205
+ if (response.status === 503) {
206
+ Logger.trace?.('http.err', `RWS1 ${method} ${path} → 503 busy`, { protocol: 'rws1', method, path });
207
+ throw new RwsError('Controller busy (503) — retry later', 'CONTROLLER_BUSY', 503);
208
+ }
209
+ }
210
+
211
+ if (!this.isOk(response.status)) {
212
+ const bodyText = await response.text().catch(() => '');
213
+ Logger.trace?.('http.err', `RWS1 ${method} ${path} → ${response.status}`, {
214
+ protocol: 'rws1', method, path, status: response.status,
215
+ durationMs: Date.now() - startedAt,
216
+ bodyPreview: bodyText.slice(0, 300),
217
+ });
218
+ throw new RwsError(
219
+ `HTTP ${response.status} from ${method} ${path}`,
220
+ this.mapHttpStatus(response.status),
221
+ response.status,
222
+ bodyText,
223
+ );
224
+ }
225
+ Logger.trace?.('http.res', `RWS1 ${method} ${path} → ${response.status}`, {
226
+ protocol: 'rws1', method, path, status: response.status,
227
+ durationMs: Date.now() - startedAt,
228
+ });
229
+
230
+ // Store cookies from response
231
+ this.storeCookies(response.headers);
232
+ this.lastActivityTime = Date.now();
233
+
234
+ const bodyText = await response.text().catch(() => '');
235
+ return { status: response.status, body: bodyText, headers: response.headers };
236
+ }
237
+
238
+ /** Issue a single HTTP request with digest auth header if we have a challenge */
239
+ private async rawFetch(
240
+ method: string,
241
+ path: string,
242
+ body?: string | Uint8Array,
243
+ ): Promise<Response> {
244
+ const url = `${this.baseUrl}${path}`;
245
+ const controller = new AbortController();
246
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
247
+
248
+ const headers: Record<string, string> = {
249
+ Accept: 'application/xhtml+xml, application/json',
250
+ };
251
+
252
+ if (body !== undefined) {
253
+ if (body instanceof Uint8Array) {
254
+ headers['Content-Type'] = 'application/octet-stream';
255
+ } else {
256
+ // Always set Content-Type for form submissions, even with empty body (e.g. resetpp)
257
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
258
+ }
259
+ }
260
+
261
+ const cookieHeader = this.buildCookieHeader();
262
+ if (cookieHeader) {
263
+ headers['Cookie'] = cookieHeader;
264
+ }
265
+
266
+ if (this.digestChallenge) {
267
+ // The URI in the Authorization header and HA2 computation must be the path + query,
268
+ // NOT the full URL with scheme and host — a common implementation mistake.
269
+ headers['Authorization'] = this.buildAuthHeader(method, path);
270
+ }
271
+
272
+ try {
273
+ return await fetch(url, {
274
+ method,
275
+ headers,
276
+ body: body !== undefined ? body : undefined,
277
+ signal: controller.signal,
278
+ });
279
+ } catch (e) {
280
+ if (e instanceof Error && e.name === 'AbortError') {
281
+ throw new RwsError(`Request timed out after ${this.timeoutMs}ms: ${method} ${path}`, 'NETWORK_ERROR');
282
+ }
283
+ throw new RwsError(`Network error: ${String(e)}`, 'NETWORK_ERROR');
284
+ } finally {
285
+ clearTimeout(timer);
286
+ }
287
+ }
288
+
289
+ // ─── Digest auth ────────────────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Parse the WWW-Authenticate: Digest ... header into a DigestChallenge.
293
+ * Handles both quoted and unquoted parameter values per RFC 2617.
294
+ */
295
+ private parseDigestChallenge(header: string): DigestChallenge {
296
+ const prefix = 'Digest ';
297
+ if (!header.toLowerCase().startsWith('digest ')) {
298
+ throw new RwsError(`Unsupported auth scheme: "${header}"`, 'AUTH_FAILED');
299
+ }
300
+ const paramString = header.slice(prefix.length);
301
+
302
+ // Match key="quoted value" or key=unquoted-value
303
+ const paramPattern = /(\w+)=(?:"([^"]*)"|([\w.!@#$%^&*()\-_+=[\]{};:'<>,./\\?~`|]+))/g;
304
+ const params: Record<string, string> = {};
305
+ let m: RegExpExecArray | null;
306
+ while ((m = paramPattern.exec(paramString)) !== null) {
307
+ // m[2] = quoted value, m[3] = unquoted value
308
+ params[m[1].toLowerCase()] = m[2] ?? m[3] ?? '';
309
+ }
310
+
311
+ if (!params['realm']) throw new RwsError('WWW-Authenticate missing realm', 'AUTH_FAILED');
312
+ if (!params['nonce']) throw new RwsError('WWW-Authenticate missing nonce', 'AUTH_FAILED');
313
+
314
+ return {
315
+ realm: params['realm'],
316
+ nonce: params['nonce'],
317
+ opaque: params['opaque'],
318
+ qop: params['qop'],
319
+ algorithm: params['algorithm'] ?? 'MD5',
320
+ stale: params['stale']?.toLowerCase() === 'true',
321
+ domain: params['domain'],
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Build the Authorization: Digest ... header value for the given request.
327
+ * Increments the nonce use counter (nc).
328
+ *
329
+ * RFC 2617 §3.2.2:
330
+ * HA1 = MD5(username:realm:password)
331
+ * HA2 = MD5(method:digestURI)
332
+ * response = MD5(HA1:nonce:nc:cnonce:qop:HA2) — when qop=auth
333
+ * response = MD5(HA1:nonce:HA2) — RFC 2069 compat (no qop)
334
+ *
335
+ * Important: the space in 'Default User' is NOT percent-encoded for HA1.
336
+ * The nc value is NOT quoted in the Authorization header.
337
+ * The URI is path+query only, not scheme://host:port/path.
338
+ */
339
+ private buildAuthHeader(method: string, uri: string): string {
340
+ const challenge = this.digestChallenge!;
341
+
342
+ // qop may be a comma-separated list, e.g. qop="auth,auth-int". Pick auth when
343
+ // offered; auth-int (body-hash mode) is not implemented, so an auth-int-only
344
+ // challenge must fail loudly rather than answer with a plain-auth hash.
345
+ let useQopAuth = false;
346
+ if (challenge.qop) {
347
+ const offered = challenge.qop.split(',').map((q) => q.trim());
348
+ if (!offered.includes('auth')) {
349
+ throw new RwsError(
350
+ `Digest qop "${challenge.qop}" is not supported — only qop=auth`,
351
+ 'AUTH_FAILED',
352
+ );
353
+ }
354
+ useQopAuth = true;
355
+ }
356
+
357
+ const nc = (++this.nonceCount).toString(16).padStart(8, '0');
358
+ const cnonce = randomBytes(16).toString('hex');
359
+
360
+ const ha1 = md5(`${this.username}:${challenge.realm}:${this.password}`);
361
+ const ha2 = md5(`${method}:${uri}`);
362
+
363
+ let responseHash: string;
364
+ if (useQopAuth) {
365
+ // RFC 2617 qop mode
366
+ responseHash = md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:auth:${ha2}`);
367
+ } else {
368
+ // RFC 2069 compat — no qop
369
+ responseHash = md5(`${ha1}:${challenge.nonce}:${ha2}`);
370
+ }
371
+
372
+ const parts = [
373
+ `Digest username="${this.username}"`,
374
+ `realm="${challenge.realm}"`,
375
+ `nonce="${challenge.nonce}"`,
376
+ `uri="${uri}"`,
377
+ `algorithm=MD5`, // nc is NOT quoted
378
+ `nc=${nc}`,
379
+ `cnonce="${cnonce}"`,
380
+ `response="${responseHash}"`,
381
+ ];
382
+
383
+ if (useQopAuth) {
384
+ parts.push(`qop=auth`);
385
+ }
386
+ if (challenge.opaque) {
387
+ parts.push(`opaque="${challenge.opaque}"`);
388
+ }
389
+
390
+ return parts.join(', ');
391
+ }
392
+
393
+ // ─── Cookie management ───────────────────────────────────────────────────────
394
+
395
+ /**
396
+ * Extract Set-Cookie headers from a response and store name=value pairs.
397
+ * Uses Headers.getSetCookie() (Node 18.14.1+) to correctly handle multiple
398
+ * Set-Cookie headers. Falls back to headers.get('set-cookie') on older Node 18,
399
+ * though this may misparse cookie values containing commas.
400
+ */
401
+ private storeCookies(headers: Headers): void {
402
+ let setCookies: string[];
403
+
404
+ // getSetCookie() is the WHATWG-spec method returning string[] — available Node 18.14.1+
405
+ if (typeof (headers as { getSetCookie?: () => string[] }).getSetCookie === 'function') {
406
+ setCookies = (headers as { getSetCookie: () => string[] }).getSetCookie();
407
+ } else {
408
+ // Fallback: merge header may be comma-split incorrectly for complex cookie values,
409
+ // but acceptable for the simple ABBCX and -http-session- cookies IRC5 sends.
410
+ const merged = headers.get('set-cookie');
411
+ setCookies = merged ? merged.split(/,\s*(?=[^;,]+=)/) : [];
412
+ }
413
+
414
+ for (const cookie of setCookies) {
415
+ // Only take the name=value part before the first ';'
416
+ const [nameValue] = cookie.split(';');
417
+ if (!nameValue) continue;
418
+ const eqIdx = nameValue.indexOf('=');
419
+ if (eqIdx === -1) continue;
420
+ const name = nameValue.slice(0, eqIdx).trim();
421
+ const value = nameValue.slice(eqIdx + 1).trim();
422
+ if (name) this.cookies.set(name, value);
423
+ }
424
+ }
425
+
426
+ private buildCookieHeader(): string {
427
+ return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
428
+ }
429
+
430
+ // ─── Session expiry ──────────────────────────────────────────────────────────
431
+
432
+ private isSessionExpired(): boolean {
433
+ return (
434
+ this.lastActivityTime > 0 &&
435
+ Date.now() - this.lastActivityTime > HttpSession.SESSION_TIMEOUT_MS
436
+ );
437
+ }
438
+
439
+ // ─── Helpers ────────────────────────────────────────────────────────────────
440
+
441
+ private isOk(status: number): boolean {
442
+ return status >= 200 && status < 300;
443
+ }
444
+
445
+ private mapHttpStatus(status: number): RwsError['code'] {
446
+ switch (status) {
447
+ case 401:
448
+ case 403:
449
+ return 'AUTH_FAILED';
450
+ case 404:
451
+ return 'MODULE_NOT_FOUND';
452
+ case 429:
453
+ return 'RATE_LIMITED';
454
+ case 503:
455
+ return 'CONTROLLER_BUSY';
456
+ default:
457
+ return 'UNKNOWN';
458
+ }
459
+ }
460
+ }