appmesh 1.2.3 → 1.3.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.
@@ -6,10 +6,12 @@ import fs from 'fs'
6
6
  import os from 'os'
7
7
  import { v1 as uuidv1 } from 'uuid'
8
8
  import msgpack from 'msgpack-lite'
9
- import AppMeshClient from './appmesh.js'
9
+ import AppMeshClient, { AppMeshError, TotpRequiredError, AppRemovedError, TransportDisconnectedError, DEFAULT_CA_FILE } from './appmesh.js'
10
10
 
11
11
  // Constants
12
12
  const TCP_BLOCK_SIZE = 16 * 1024 - 128 // TLS-optimized chunk size
13
+ const TCP_MESSAGE_MAGIC = 0x07C707F8 // must match TCP_MESSAGE_MAGIC in src/common/Utility.h
14
+ const TCP_MESSAGE_HEADER_LENGTH = 8 // 4-byte magic + 4-byte body length, big-endian
13
15
  const ENCODING_UTF8 = 'utf-8'
14
16
  const HTTP_USER_AGENT_TCP = 'appmesh/nodejs/tcp'
15
17
  const HTTP_HEADER_KEY_X_SEND_FILE_SOCKET = 'X-Send-File-Socket'
@@ -18,10 +20,31 @@ const HTTP_HEADER_KEY_USER_AGENT = 'User-Agent'
18
20
  const HTTP_HEADER_KEY_X_FILE_PATH = 'X-File-Path'
19
21
  const HTTP_HEADER_KEY_AUTH = 'Authorization'
20
22
 
23
+ // Auth endpoints returning a new access_token in the JSON body (allowlist per
24
+ // docs/source/SDKContract.md "Auth Token Synchronization").
25
+ // Login/auth/totp_validate: apply token only when the request carried X-Set-Cookie: true
26
+ const AUTH_SET_COOKIE_PATHS = new Set(['/appmesh/login', '/appmesh/auth', '/appmesh/totp/validate'])
27
+ // Renew/setup: always apply (client already has an active session)
28
+ const AUTH_RENEW_PATHS = new Set(['/appmesh/token/renew', '/appmesh/totp/setup'])
29
+ const LOGOFF_PATH = '/appmesh/self/logoff'
30
+
21
31
  /**
22
32
  * TCP transport for secure TLS connections and framed App Mesh messages.
33
+ *
34
+ * `sslConfig` is a tri-state:
35
+ * - `null`/`undefined` (default): verify the server certificate against the App Mesh default
36
+ * CA (/opt/appmesh/ssl/ca.pem) if installed, otherwise the system CAs.
37
+ * - object: custom TLS material — `ca`/`cert`/`key` (Buffer or file path) with an optional
38
+ * `rejectUnauthorized` flag (defaults to `true`). An unreadable CA/cert/key path is a hard
39
+ * error (`fs.readFileSync` throws), never a silent fallback to no-verification.
40
+ * - `false`: explicit insecure mode — server certificate verification is disabled.
41
+ * This is the only way to disable verification besides `rejectUnauthorized: false`.
23
42
  */
24
43
  class TCPTransport {
44
+ /**
45
+ * @param {Array<string, number>} [address=['127.0.0.1', 6059]] - Server address [host, port]
46
+ * @param {Object|false|null} [sslConfig=null] - TLS tri-state (see class doc)
47
+ */
25
48
  constructor (address = ['127.0.0.1', 6059], sslConfig = null) {
26
49
  this.address = address
27
50
  this.sslConfig = sslConfig
@@ -31,10 +54,11 @@ class TCPTransport {
31
54
 
32
55
  /**
33
56
  * Establish a TLS connection to the server if one is not already open.
57
+ * @returns {Promise<void>} Always a Promise, resolved immediately when already connected.
34
58
  */
35
59
  connect () {
36
60
  if (this.connected()) {
37
- return
61
+ return Promise.resolve()
38
62
  }
39
63
 
40
64
  const options = {
@@ -44,7 +68,10 @@ class TCPTransport {
44
68
  }
45
69
 
46
70
  // Handle SSL configuration
47
- if (this.sslConfig) {
71
+ if (this.sslConfig === false) {
72
+ // Explicit insecure mode
73
+ options.rejectUnauthorized = false
74
+ } else if (this.sslConfig) {
48
75
  // Handle rejectUnauthorized
49
76
  if (typeof this.sslConfig.rejectUnauthorized === 'boolean') {
50
77
  options.rejectUnauthorized = this.sslConfig.rejectUnauthorized
@@ -70,8 +97,9 @@ class TCPTransport {
70
97
  ? this.sslConfig.key
71
98
  : fs.readFileSync(this.sslConfig.key)
72
99
  }
73
- } else if (this.sslConfig === false) {
74
- options.rejectUnauthorized = false
100
+ } else if (fs.existsSync(DEFAULT_CA_FILE)) {
101
+ // Auto default: prefer the App Mesh default CA; absent → system CAs, verification stays on
102
+ options.ca = fs.readFileSync(DEFAULT_CA_FILE)
75
103
  }
76
104
 
77
105
  this.socket = tls.connect(options)
@@ -106,63 +134,67 @@ class TCPTransport {
106
134
  }
107
135
 
108
136
  /**
109
- * Send a message over the socket
110
- * @param {Buffer|Array} data - Data to send (empty array signals EOF)
137
+ * Send a message over the socket, framed as 4-byte magic + 4-byte length header
138
+ * (big-endian), matching the daemon wire protocol.
139
+ * @param {Buffer|Array} data - Data to send (empty array signals EOF: magic + zero length)
111
140
  */
112
141
  sendMessage (data) {
113
142
  if (!this.connected()) {
114
143
  throw new Error('Socket not connected')
115
144
  }
116
145
 
117
- let payload
118
- if (Array.isArray(data) && data.length === 0) {
119
- // EOF signal
120
- payload = Buffer.alloc(4)
121
- payload.writeUInt32BE(0, 0)
122
- } else {
123
- const msgData = Buffer.isBuffer(data) ? data : Buffer.from(data)
124
- const length = msgData.length
125
- payload = Buffer.alloc(4 + length)
126
- payload.writeUInt32BE(length, 0)
127
- msgData.copy(payload, 4)
128
- }
146
+ const msgData = (Array.isArray(data) && data.length === 0)
147
+ ? Buffer.alloc(0) // EOF signal
148
+ : (Buffer.isBuffer(data) ? data : Buffer.from(data))
149
+
150
+ const payload = Buffer.alloc(TCP_MESSAGE_HEADER_LENGTH + msgData.length)
151
+ payload.writeUInt32BE(TCP_MESSAGE_MAGIC, 0)
152
+ payload.writeUInt32BE(msgData.length, 4)
153
+ msgData.copy(payload, TCP_MESSAGE_HEADER_LENGTH)
129
154
 
130
155
  this.socket.write(payload)
131
156
  }
132
157
 
133
158
  /**
134
- * Receive a message from the socket
135
- * @returns {Promise<Buffer|null>} Received data or null for EOF
159
+ * Receive a message framed as 4-byte magic + 4-byte length header (big-endian).
160
+ * @returns {Promise<Buffer|null>} Received data, empty Buffer for EOF signal, or null when
161
+ * the connection closed
162
+ * @throws {Error} If the header magic number does not match the daemon protocol
136
163
  */
137
164
  async receiveMessage () {
138
165
  if (!this.connected()) {
139
166
  return null
140
167
  }
141
168
 
142
- // Read message length (4 bytes)
143
- while (this.receiveBuffer.length < 4) {
169
+ // Read message header (magic + length)
170
+ while (this.receiveBuffer.length < TCP_MESSAGE_HEADER_LENGTH) {
144
171
  const chunk = await this._readChunk()
145
172
  if (!chunk) return null
146
173
  this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk])
147
174
  }
148
175
 
149
- const length = this.receiveBuffer.readUInt32BE(0)
176
+ const magic = this.receiveBuffer.readUInt32BE(0)
177
+ if (magic !== TCP_MESSAGE_MAGIC) {
178
+ this.close()
179
+ throw new Error(`Invalid TCP message magic number: 0x${magic.toString(16).toUpperCase()}`)
180
+ }
181
+ const length = this.receiveBuffer.readUInt32BE(4)
150
182
 
151
183
  // EOF signal
152
184
  if (length === 0) {
153
- this.receiveBuffer = this.receiveBuffer.slice(4)
185
+ this.receiveBuffer = this.receiveBuffer.slice(TCP_MESSAGE_HEADER_LENGTH)
154
186
  return Buffer.alloc(0)
155
187
  }
156
188
 
157
189
  // Read message body
158
- while (this.receiveBuffer.length < 4 + length) {
190
+ while (this.receiveBuffer.length < TCP_MESSAGE_HEADER_LENGTH + length) {
159
191
  const chunk = await this._readChunk()
160
192
  if (!chunk) return null
161
193
  this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk])
162
194
  }
163
195
 
164
- const message = this.receiveBuffer.slice(4, 4 + length)
165
- this.receiveBuffer = this.receiveBuffer.slice(4 + length)
196
+ const message = this.receiveBuffer.slice(TCP_MESSAGE_HEADER_LENGTH, TCP_MESSAGE_HEADER_LENGTH + length)
197
+ this.receiveBuffer = this.receiveBuffer.slice(TCP_MESSAGE_HEADER_LENGTH + length)
166
198
  return message
167
199
  }
168
200
 
@@ -259,6 +291,27 @@ class ResponseMessage {
259
291
  }
260
292
  }
261
293
 
294
+ /**
295
+ * True when `value` looks like a {host, port} address object rather than an SSL config.
296
+ * @private
297
+ */
298
+ function _isAddressObject (value) {
299
+ return !!value && typeof value === 'object' && !Array.isArray(value) && !Buffer.isBuffer(value) &&
300
+ ('host' in value || 'port' in value) &&
301
+ !('ca' in value) && !('cert' in value) && !('key' in value) && !('rejectUnauthorized' in value)
302
+ }
303
+
304
+ /**
305
+ * Normalize a TCP address given as [host, port] (legacy) or {host, port} to [host, port].
306
+ * @private
307
+ */
308
+ function _normalizeTcpAddress (address) {
309
+ if (_isAddressObject(address)) {
310
+ return [address.host ?? '127.0.0.1', address.port ?? 6059]
311
+ }
312
+ return address
313
+ }
314
+
262
315
  /**
263
316
  * TCP-based App Mesh client with optimized file transfer support.
264
317
  *
@@ -271,14 +324,34 @@ class AppMeshClientTCP extends AppMeshClient {
271
324
  /**
272
325
  * Create a TCP-based App Mesh client.
273
326
  *
274
- * @param {Object} [sslConfig=null] - SSL configuration object
327
+ * Two constructor forms are accepted:
328
+ * - `new AppMeshClientTCP(sslConfig, [host, port])` — legacy positional form; the address
329
+ * is a `[host, port]` array.
330
+ * - `new AppMeshClientTCP({host, port}, sslConfig)` or
331
+ * `new AppMeshClientTCP(sslConfig, {host, port})` — the address may be an explicit
332
+ * `{host, port}` object in either position.
333
+ *
334
+ * `sslConfig` is a tri-state: `null`/`undefined` verifies the server certificate against the
335
+ * App Mesh default CA (/opt/appmesh/ssl/ca.pem) if installed, otherwise the system CAs; an
336
+ * object supplies custom TLS material (an unreadable ca/cert/key path is a hard error, never
337
+ * a silent no-verify fallback); `false` is the explicit insecure no-verification mode.
338
+ *
339
+ * @param {Object|false|null} [sslConfig=null] - SSL configuration (or `{host, port}` address)
275
340
  * @param {Buffer|string} [sslConfig.ca] - CA certificate
276
341
  * @param {Buffer|string} [sslConfig.cert] - Client certificate
277
342
  * @param {Buffer|string} [sslConfig.key] - Client key
278
343
  * @param {boolean} [sslConfig.rejectUnauthorized=true] - Whether to verify SSL
279
- * @param {Array<string, number>} [tcpAddress=['127.0.0.1', 6059]] - TCP server address [host, port]
344
+ * @param {Array<string, number>|{host: string, port: number}} [tcpAddress=['127.0.0.1', 6059]] - TCP server address
280
345
  */
281
346
  constructor (sslConfig = null, tcpAddress = ['127.0.0.1', 6059]) {
347
+ // Accept the address-first object form: new AppMeshClientTCP({host, port}, sslConfig)
348
+ if (_isAddressObject(sslConfig)) {
349
+ const addr = sslConfig
350
+ sslConfig = (Array.isArray(tcpAddress) || _isAddressObject(tcpAddress)) ? null : tcpAddress
351
+ tcpAddress = addr
352
+ }
353
+ tcpAddress = _normalizeTcpAddress(tcpAddress)
354
+
282
355
  // Pass dummy baseURL to parent - not used for TCP communication
283
356
  super('https://127.0.0.1:6060', sslConfig)
284
357
  this.tcpTransport = new TCPTransport(tcpAddress, sslConfig)
@@ -322,42 +395,47 @@ class AppMeshClientTCP extends AppMeshClient {
322
395
  * @override
323
396
  */
324
397
  _handleTokenUpdate (token) {
398
+ // Base implementation stores the token and handles refresh scheduling
399
+ super._handleTokenUpdate(token)
325
400
  this._token = token || ''
326
- // Call parent implementation if it exists (for token refresh scheduling, etc.)
327
- if (super._handleTokenUpdate) {
328
- super._handleTokenUpdate(token)
329
- }
330
401
  }
331
402
 
332
403
  /**
333
- * Extract and store a token from a login/renew response body.
334
- *
335
- * Since TCP doesn't support Set-Cookie headers, we need to extract
336
- * the token from response body after login/authenticate operations.
337
- *
338
- * @param {Object} response - Response object from _request
404
+ * Extract and apply a token from auth endpoint responses. TCP has no Set-Cookie,
405
+ * so the token comes from the JSON body — only on HTTP 200 and only for the
406
+ * SDKContract.md auth endpoint allowlist.
407
+ * @param {Object} response - Normalized response object from _request
408
+ * @param {string} path - Request URI path
409
+ * @param {Object} requestHeaders - Headers sent with the request
339
410
  * @private
340
411
  */
341
- _extractTokenFromResponse (response) {
342
- // Check if response contains access_token (from login/renew)
343
- if (response.data) {
344
- let data = response.data
412
+ _syncTransportToken (response, path, requestHeaders) {
413
+ if (response.status !== 200) return
345
414
 
346
- // Parse if it's a Buffer containing JSON
347
- if (Buffer.isBuffer(data)) {
348
- try {
349
- data = JSON.parse(data.toString(ENCODING_UTF8))
350
- } catch (e) {
351
- // Not JSON, ignore
352
- return
353
- }
354
- }
415
+ if (path === LOGOFF_PATH) {
416
+ this._handleTokenUpdate(null)
417
+ return
418
+ }
355
419
 
356
- // Extract and store token
357
- if (data.access_token) {
358
- this._handleTokenUpdate(data.access_token)
420
+ // Login/auth/totp_validate: apply only when client requested cookie mode
421
+ if (AUTH_SET_COOKIE_PATHS.has(path)) {
422
+ if (!requestHeaders || requestHeaders['X-Set-Cookie'] !== 'true') return
423
+ } else if (!AUTH_RENEW_PATHS.has(path)) {
424
+ return
425
+ }
426
+
427
+ let data = response.data
428
+ // Parse if it's a Buffer containing JSON
429
+ if (Buffer.isBuffer(data)) {
430
+ try {
431
+ data = JSON.parse(data.toString(ENCODING_UTF8))
432
+ } catch (e) {
433
+ return // Not JSON, ignore
359
434
  }
360
435
  }
436
+ if (data && data.access_token) {
437
+ this._handleTokenUpdate(data.access_token)
438
+ }
361
439
  }
362
440
 
363
441
  /**
@@ -408,15 +486,24 @@ class AppMeshClientTCP extends AppMeshClient {
408
486
 
409
487
  // Send request
410
488
  const serialized = request.serialize()
411
- this.tcpTransport.sendMessage(serialized)
412
489
 
413
490
  let response
414
- if (this._demuxer) {
415
- // Demuxer active: route response back via UUID matching.
416
- const resp = await this._demuxer.registerRequest(request.uuid)
417
- response = resp
491
+ if (this._demuxer && this._demuxer._running) {
492
+ // Live demuxer: route response by UUID. A stopped demuxer can never resolve
493
+ // waiters, so fall back to the direct read below. Contract S7: register the
494
+ // waiter BEFORE writing request bytes so a fast response can't be dropped.
495
+ const respPromise = this._demuxer.registerRequest(request.uuid)
496
+ try {
497
+ this.tcpTransport.sendMessage(serialized)
498
+ } catch (e) {
499
+ respPromise.catch(() => {}) // never dispatched; silence a later stop() rejection
500
+ this._demuxer.unregisterRequest(request.uuid)
501
+ throw e
502
+ }
503
+ response = await respPromise
418
504
  } else {
419
505
  // Legacy synchronous mode: read directly.
506
+ this.tcpTransport.sendMessage(serialized)
420
507
  const respData = await this.tcpTransport.receiveMessage()
421
508
  if (!respData || respData.length === 0) {
422
509
  this.tcpTransport.close()
@@ -430,16 +517,60 @@ class AppMeshClientTCP extends AppMeshClient {
430
517
  status: response.httpStatus,
431
518
  statusText: this._getStatusText(response.httpStatus),
432
519
  headers: response.headers,
433
- data: response.body,
520
+ data: this._decodeBody(response, options),
434
521
  config: options
435
522
  }
436
523
 
437
- // Extract token from response if present (for login, authenticate, renew_token)
438
- this._extractTokenFromResponse(result)
524
+ // Sync token from auth endpoint responses (login, authenticate, renew_token, logoff)
525
+ this._syncTransportToken(result, path, request.headers)
526
+
527
+ // Mirror the HTTP transport: never swallow a non-200 into a success value
528
+ if (result.status !== 200) {
529
+ const errMsg = this._extractErrorMessage(result.data)
530
+ if (result.status === 428) {
531
+ throw new TotpRequiredError(errMsg, result.data)
532
+ }
533
+ throw new AppMeshError(errMsg, result.status, result.data)
534
+ }
439
535
 
440
536
  return result
441
537
  }
442
538
 
539
+ /**
540
+ * Decode a raw msgpack body Buffer to the same shape axios produces over HTTP:
541
+ * parsed JSON object for JSON payloads, string for text, Buffer only for binary
542
+ * responses (responseType 'arraybuffer'/'stream').
543
+ * @private
544
+ */
545
+ _decodeBody (response, options) {
546
+ const data = response.body
547
+ if (!Buffer.isBuffer(data)) {
548
+ return data
549
+ }
550
+
551
+ const responseType = options.config?.responseType
552
+ if (responseType === 'arraybuffer' || responseType === 'stream') {
553
+ return data
554
+ }
555
+
556
+ const headers = response.headers || {}
557
+ const ctKey = Object.keys(headers).find(k => k.toLowerCase() === 'content-type')
558
+ const contentType = (ctKey ? String(headers[ctKey]) : '').toLowerCase()
559
+ if (!responseType && contentType.includes('application/octet-stream')) {
560
+ return data
561
+ }
562
+
563
+ const text = data.toString(ENCODING_UTF8)
564
+ if (responseType === 'text' || contentType.startsWith('text/')) {
565
+ return text
566
+ }
567
+ try {
568
+ return JSON.parse(text)
569
+ } catch (_) {
570
+ return text // not JSON, keep raw text (axios does the same)
571
+ }
572
+ }
573
+
443
574
  /**
444
575
  * Get HTTP status text
445
576
  * @private
@@ -483,10 +614,6 @@ class AppMeshClientTCP extends AppMeshClient {
483
614
  }
484
615
  )
485
616
 
486
- if (response.status !== 200) {
487
- throw new Error(`Download failed: ${response.statusText}`)
488
- }
489
-
490
617
  if (!response.headers[HTTP_HEADER_KEY_X_RECV_FILE_SOCKET]) {
491
618
  throw new Error(
492
619
  `Server did not respond with socket transfer option: ${HTTP_HEADER_KEY_X_RECV_FILE_SOCKET}`
@@ -509,8 +636,9 @@ class AppMeshClientTCP extends AppMeshClient {
509
636
 
510
637
  // Apply file attributes on Unix systems
511
638
  if (applyAttrs && process.platform !== 'win32') {
639
+ // Mode is sent as a decimal string (matches the HTTP transport and Python SDK)
512
640
  if (response.headers['X-File-Mode']) {
513
- fs.chmodSync(localFile, parseInt(response.headers['X-File-Mode'], 8))
641
+ fs.chmodSync(localFile, parseInt(response.headers['X-File-Mode'], 10))
514
642
  }
515
643
  if (response.headers['X-File-User'] && response.headers['X-File-Group']) {
516
644
  try {
@@ -560,10 +688,6 @@ class AppMeshClientTCP extends AppMeshClient {
560
688
  headers
561
689
  })
562
690
 
563
- if (response.status !== 200) {
564
- throw new Error(`Upload failed: ${response.statusText}`)
565
- }
566
-
567
691
  if (!response.headers[HTTP_HEADER_KEY_X_SEND_FILE_SOCKET]) {
568
692
  throw new Error(
569
693
  `Server did not respond with socket transfer option: ${HTTP_HEADER_KEY_X_SEND_FILE_SOCKET}`
@@ -582,6 +706,211 @@ class AppMeshClientTCP extends AppMeshClient {
582
706
  // Send EOF signal
583
707
  this.tcpTransport.sendMessage([])
584
708
  }
709
+
710
+ /**
711
+ * Subscribe to app events over TCP.
712
+ *
713
+ * @param {string} appName - Application name, or "*" for all apps.
714
+ * @param {string[]} [events] - Event types: "START", "EXIT", "STDOUT", etc.
715
+ * @param {function} callback - Called with event object for each received event.
716
+ * @returns {Promise<Object>} Subscription result with subscription_id, app_name, events.
717
+ */
718
+ async subscribe (appName, events, callback) {
719
+ this._enableDemuxer()
720
+
721
+ let path = '/appmesh/subscribe'
722
+ if (appName && appName !== '*') {
723
+ path = `/appmesh/app/${appName}/subscribe`
724
+ }
725
+
726
+ const params = {}
727
+ if (events && events.length > 0) {
728
+ params.events = events.join(',')
729
+ }
730
+
731
+ const response = await this._request('post', path, null, { params })
732
+ const result = response.data
733
+
734
+ if (callback && result.subscription_id) {
735
+ this._demuxer.registerEventCallback(result.subscription_id, callback)
736
+ }
737
+
738
+ return result
739
+ }
740
+
741
+ /**
742
+ * Unsubscribe from app events.
743
+ *
744
+ * @param {string} subscriptionId - The subscription ID to remove.
745
+ * @returns {Promise<void>}
746
+ */
747
+ async unsubscribe (subscriptionId) {
748
+ await this._request('delete', '/appmesh/subscribe', null, {
749
+ params: { subscription_id: subscriptionId }
750
+ })
751
+
752
+ if (this._demuxer) {
753
+ this._demuxer.unregisterEventCallback(subscriptionId)
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Register an app with optional atomic event subscription.
759
+ *
760
+ * @param {string} name - Application name.
761
+ * @param {Object} appJson - Application definition.
762
+ * @param {string[]} [subscribeEvents] - Event types to subscribe atomically.
763
+ * @param {function} [callback] - Event callback (required when subscribeEvents is set).
764
+ * @returns {Promise<Object>} Registered app (includes subscription_id when subscribed).
765
+ */
766
+ async add_app (name, appJson, subscribeEvents, callback) {
767
+ const options = {}
768
+ if (subscribeEvents && subscribeEvents.length > 0) {
769
+ this._enableDemuxer()
770
+ options.params = { subscribe_events: subscribeEvents.join(',') }
771
+ }
772
+
773
+ const response = await this._request('put', `/appmesh/app/${name}`, appJson, options)
774
+ const result = response.data
775
+
776
+ if (callback && result.subscription_id && this._demuxer) {
777
+ this._demuxer.registerEventCallback(result.subscription_id, callback)
778
+ }
779
+
780
+ return result
781
+ }
782
+
783
+ /**
784
+ * Enable demuxer for concurrent request-response and event routing.
785
+ * @private
786
+ */
787
+ _enableDemuxer () {
788
+ if (this._demuxer) return
789
+ this._demuxer = new MessageDemuxer(this.tcpTransport)
790
+ this._demuxer.start()
791
+ }
792
+
793
+ /**
794
+ * Subscribe-based wait for async run (TCP override).
795
+ *
796
+ * Instead of polling get_app_output in a loop, subscribes to STDOUT/EXIT/REMOVED
797
+ * events and does a one-shot backfill to cover output emitted before the subscribe
798
+ * took effect. Deduplicates by byte-position offset.
799
+ *
800
+ * No sentinel exit codes (real exit codes may be negative for signal kills):
801
+ * non-EXIT terminations throw typed errors; null means caller-side timeout only.
802
+ *
803
+ * @param {AppRun} run - AppRun object
804
+ * @param {Function} [stdoutHandler] - Stdout handler callback(data, position)
805
+ * @param {number} [timeout=0] - Max wait time in seconds (0 = unlimited)
806
+ * @returns {Promise<number|null>} Exit code, or null on timeout
807
+ * @throws {AppRemovedError} If the app was removed before its exit was observed.
808
+ * @throws {TransportDisconnectedError} If the demuxer disconnected while waiting,
809
+ * or the daemon delivered an unparseable exit code.
810
+ */
811
+ async wait_for_async_run (run, stdoutHandler, timeout = 0) {
812
+ if (!run || !run.appName) return null
813
+
814
+ let exitCode = null
815
+ let failure = null
816
+ let deliveredUntil = 0 // next-byte offset already delivered
817
+ let done = false
818
+ let resolveWait
819
+
820
+ const waitPromise = new Promise(resolve => { resolveWait = resolve })
821
+
822
+ const deliver = (chunk, pos) => {
823
+ if (!chunk) return
824
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : Buffer.from(chunk)
825
+ const end = pos + buf.length
826
+ if (end <= deliveredUntil) return
827
+ let output = buf
828
+ let startPos = pos
829
+ if (pos < deliveredUntil) {
830
+ output = buf.slice(deliveredUntil - pos)
831
+ startPos = deliveredUntil
832
+ }
833
+ deliveredUntil = end
834
+ if (stdoutHandler) {
835
+ try { stdoutHandler(output.toString('utf-8'), startPos) } catch (_) { /* ignore */ }
836
+ }
837
+ }
838
+
839
+ const onEvent = (event) => {
840
+ if (event.event_type === 'STDOUT') {
841
+ const pos = parseInt(event.data?.position ?? 0, 10) || 0
842
+ deliver(event.data?.output ?? '', pos)
843
+ } else if (event.event_type === 'EXIT') {
844
+ const parsed = parseInt(event.data?.exit_code, 10)
845
+ if (isNaN(parsed)) {
846
+ failure = new TransportDisconnectedError(
847
+ `EXIT event for '${run.appName}' carried an unparseable exit_code: ${event.data?.exit_code}`)
848
+ } else {
849
+ exitCode = parsed
850
+ }
851
+ done = true
852
+ resolveWait()
853
+ } else if (event.event_type === 'REMOVED') {
854
+ if (exitCode === null && failure === null) {
855
+ failure = new AppRemovedError(`app '${run.appName}' was removed before its exit was observed`)
856
+ }
857
+ done = true
858
+ resolveWait()
859
+ } else if (event.event_type === EVENT_TYPE_DISCONNECTED) {
860
+ if (exitCode === null && failure === null) {
861
+ failure = new TransportDisconnectedError(`transport disconnected while waiting for '${run.appName}' to exit`)
862
+ }
863
+ done = true
864
+ resolveWait()
865
+ }
866
+ }
867
+
868
+ const sub = await this.subscribe(run.appName, ['STDOUT', 'EXIT', 'REMOVED'], onEvent)
869
+
870
+ try {
871
+ // Backfill output emitted before subscribe took effect
872
+ try {
873
+ const backfill = await this.get_app_output(run.appName, 0, 0, 0, run.procUid, 0)
874
+ if (backfill.output) {
875
+ deliver(backfill.output, 0)
876
+ }
877
+ if (backfill.exitCode !== null && exitCode === null) {
878
+ exitCode = backfill.exitCode
879
+ done = true
880
+ resolveWait()
881
+ }
882
+ } catch (_) {
883
+ // backfill is best-effort
884
+ }
885
+
886
+ if (!done) {
887
+ if (timeout > 0) {
888
+ await Promise.race([
889
+ waitPromise,
890
+ new Promise(resolve => setTimeout(resolve, timeout * 1000))
891
+ ])
892
+ } else {
893
+ await waitPromise
894
+ }
895
+ }
896
+ } finally {
897
+ try {
898
+ if (sub && sub.subscription_id) {
899
+ await this.unsubscribe(sub.subscription_id)
900
+ }
901
+ } catch (_) { /* ignore */ }
902
+
903
+ // Best-effort delete on a real exit; on REMOVED/disconnect the app is already gone.
904
+ if (exitCode !== null && failure === null) {
905
+ try {
906
+ await this.delete_app(run.appName)
907
+ } catch (_) { /* ignore */ }
908
+ }
909
+ }
910
+
911
+ if (failure !== null) throw failure
912
+ return exitCode
913
+ }
585
914
  }
586
915
 
587
916
  const EVENT_URI = '/appmesh/event'
@@ -596,6 +925,8 @@ const EVENT_TYPE_DISCONNECTED = '__disconnected__'
596
925
  /**
597
926
  * Routes incoming messages to either pending request promises (by UUID) or
598
927
  * event subscription callbacks. Replaces the broken dual-reader pattern.
928
+ * Events are dispatched to subscription callbacks synchronously from the single
929
+ * read loop, in arrival order (matches the other SDK demuxers).
599
930
  */
600
931
  class MessageDemuxer {
601
932
  // Bound the pre-registration event buffer (atomic-subscribe race window) so a
@@ -622,12 +953,15 @@ class MessageDemuxer {
622
953
  }
623
954
 
624
955
  stop () {
956
+ // Idempotent: _readLoop calls stop() again when it exits after an explicit
957
+ // stop — don't broadcast a second synthetic DISCONNECTED event.
958
+ if (!this._running) return
625
959
  this._running = false
626
960
  // Broadcast a synthetic disconnect event to all registered event callbacks
627
961
  // so long-running waits can unblock cleanly.
628
962
  this._broadcastDisconnect()
629
963
  for (const [, entry] of this._pending) {
630
- entry.reject(new Error('Demuxer stopped'))
964
+ entry.reject(new TransportDisconnectedError('connection lost while waiting for response'))
631
965
  }
632
966
  this._pending.clear()
633
967
  this._eventBuffers.clear() // drop events buffered for never-registered subs
@@ -745,203 +1079,5 @@ class MessageDemuxer {
745
1079
  }
746
1080
  }
747
1081
 
748
- /**
749
- * Subscribe to app events over TCP.
750
- *
751
- * @param {string} appName - Application name, or "*" for all apps.
752
- * @param {string[]} [events] - Event types: "START", "EXIT", "STDOUT", etc.
753
- * @param {function} callback - Called with event object for each received event.
754
- * @returns {Promise<Object>} Subscription result with subscription_id, app_name, events.
755
- */
756
- AppMeshClientTCP.prototype.subscribe = async function (appName, events, callback) {
757
- this._enableDemuxer()
758
-
759
- let path = '/appmesh/subscribe'
760
- if (appName && appName !== '*') {
761
- path = `/appmesh/app/${appName}/subscribe`
762
- }
763
-
764
- const params = {}
765
- if (events && events.length > 0) {
766
- params.events = events.join(',')
767
- }
768
-
769
- const response = await this._request('post', path, null, { params })
770
- if (response.status !== 200) {
771
- throw new Error(`Subscribe failed: ${response.statusText}`)
772
- }
773
-
774
- const result = typeof response.data === 'string' ? JSON.parse(response.data) : response.data
775
-
776
- if (callback && result.subscription_id) {
777
- this._demuxer.registerEventCallback(result.subscription_id, callback)
778
- }
779
-
780
- return result
781
- }
782
-
783
- /**
784
- * Unsubscribe from app events.
785
- *
786
- * @param {string} subscriptionId - The subscription ID to remove.
787
- * @returns {Promise<void>}
788
- */
789
- AppMeshClientTCP.prototype.unsubscribe = async function (subscriptionId) {
790
- await this._request('delete', '/appmesh/subscribe', null, {
791
- params: { subscription_id: subscriptionId }
792
- })
793
-
794
- if (this._demuxer) {
795
- this._demuxer.unregisterEventCallback(subscriptionId)
796
- }
797
- }
798
-
799
- /**
800
- * Register an app with optional atomic event subscription.
801
- *
802
- * @param {string} name - Application name.
803
- * @param {Object} appJson - Application definition.
804
- * @param {string[]} [subscribeEvents] - Event types to subscribe atomically.
805
- * @param {function} [callback] - Event callback (required when subscribeEvents is set).
806
- * @returns {Promise<Object>} Registered app (includes subscription_id when subscribed).
807
- */
808
- AppMeshClientTCP.prototype.add_app = async function (name, appJson, subscribeEvents, callback) {
809
- const options = {}
810
- if (subscribeEvents && subscribeEvents.length > 0) {
811
- this._enableDemuxer()
812
- options.params = { subscribe_events: subscribeEvents.join(',') }
813
- }
814
-
815
- const response = await this._request('put', `/appmesh/app/${name}`, appJson, options)
816
- const result = typeof response.data === 'string' ? JSON.parse(response.data) : response.data
817
-
818
- if (callback && result.subscription_id && this._demuxer) {
819
- this._demuxer.registerEventCallback(result.subscription_id, callback)
820
- }
821
-
822
- return result
823
- }
824
-
825
- /**
826
- * Enable demuxer for concurrent request-response and event routing.
827
- * @private
828
- */
829
- AppMeshClientTCP.prototype._enableDemuxer = function () {
830
- if (this._demuxer) return
831
- this._demuxer = new MessageDemuxer(this.tcpTransport)
832
- this._demuxer.start()
833
- }
834
-
835
- /**
836
- * Subscribe-based wait for async run (TCP override).
837
- *
838
- * Instead of polling get_app_output in a loop, subscribes to STDOUT/EXIT/REMOVED
839
- * events and does a one-shot backfill to cover output emitted before the subscribe
840
- * took effect. Deduplicates by byte-position offset.
841
- *
842
- * Sentinel exit codes:
843
- * null — caller-side timeout
844
- * -1 — REMOVED before EXIT observed
845
- * -2 — demuxer disconnected (transport failure)
846
- *
847
- * @param {AppRun} run - AppRun object
848
- * @param {Function} [stdoutHandler] - Stdout handler callback(data, position)
849
- * @param {number} [timeout=0] - Max wait time in seconds (0 = unlimited)
850
- * @returns {Promise<number|null>} Exit code, or null on timeout
851
- */
852
- AppMeshClientTCP.prototype.wait_for_async_run = async function (run, stdoutHandler, timeout = 0) {
853
- if (!run || !run.appName) return null
854
-
855
- let exitCode = null
856
- let deliveredUntil = 0 // next-byte offset already delivered
857
- let done = false
858
- let resolveWait
859
-
860
- const waitPromise = new Promise(resolve => { resolveWait = resolve })
861
-
862
- const deliver = (chunk, pos) => {
863
- if (!chunk) return
864
- const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : Buffer.from(chunk)
865
- const end = pos + buf.length
866
- if (end <= deliveredUntil) return
867
- let output = buf
868
- let startPos = pos
869
- if (pos < deliveredUntil) {
870
- output = buf.slice(deliveredUntil - pos)
871
- startPos = deliveredUntil
872
- }
873
- deliveredUntil = end
874
- if (stdoutHandler) {
875
- try { stdoutHandler(output.toString('utf-8'), startPos) } catch (_) { /* ignore */ }
876
- }
877
- }
878
-
879
- const onEvent = (event) => {
880
- if (event.event_type === 'STDOUT') {
881
- const pos = parseInt(event.data?.position ?? 0, 10) || 0
882
- deliver(event.data?.output ?? '', pos)
883
- } else if (event.event_type === 'EXIT') {
884
- exitCode = parseInt(event.data?.exit_code ?? -1, 10)
885
- if (isNaN(exitCode)) exitCode = -1
886
- done = true
887
- resolveWait()
888
- } else if (event.event_type === 'REMOVED') {
889
- if (exitCode === null) exitCode = -1
890
- done = true
891
- resolveWait()
892
- } else if (event.event_type === EVENT_TYPE_DISCONNECTED) {
893
- if (exitCode === null) exitCode = -2
894
- done = true
895
- resolveWait()
896
- }
897
- }
898
-
899
- const sub = await this.subscribe(run.appName, ['STDOUT', 'EXIT', 'REMOVED'], onEvent)
900
-
901
- try {
902
- // Backfill output emitted before subscribe took effect
903
- try {
904
- const backfill = await this.get_app_output(run.appName, 0, 0, 0, run.procUid, 0)
905
- if (backfill.output) {
906
- deliver(backfill.output, 0)
907
- }
908
- if (backfill.exitCode !== null && exitCode === null) {
909
- exitCode = backfill.exitCode
910
- done = true
911
- resolveWait()
912
- }
913
- } catch (_) {
914
- // backfill is best-effort
915
- }
916
-
917
- if (!done) {
918
- if (timeout > 0) {
919
- await Promise.race([
920
- waitPromise,
921
- new Promise(resolve => setTimeout(resolve, timeout * 1000))
922
- ])
923
- } else {
924
- await waitPromise
925
- }
926
- }
927
- } finally {
928
- try {
929
- if (sub && sub.subscription_id) {
930
- await this.unsubscribe(sub.subscription_id)
931
- }
932
- } catch (_) { /* ignore */ }
933
-
934
- // Best-effort delete on a real exit (>=0).
935
- // Sentinels (-1 REMOVED, -2 disconnected) mean the app is already gone.
936
- if (exitCode !== null && exitCode >= 0) {
937
- try {
938
- await this.delete_app(run.appName)
939
- } catch (_) { /* ignore */ }
940
- }
941
- }
942
-
943
- return exitCode
944
- }
945
-
946
1082
  export { AppMeshClientTCP, TCPTransport, RequestMessage, ResponseMessage, MessageDemuxer, EVENT_TYPE_DISCONNECTED }
947
1083
  export default AppMeshClientTCP