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.
- package/README.md +64 -48
- package/dist/appmesh.esm.js +1 -1
- package/package.json +6 -1
- package/src/appmesh.js +308 -134
- package/src/appmesh_tcp.js +408 -272
- package/src/server.js +175 -81
package/src/appmesh_tcp.js
CHANGED
|
@@ -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 (
|
|
74
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
135
|
-
* @returns {Promise<Buffer|null>} Received data
|
|
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
|
|
143
|
-
while (this.receiveBuffer.length <
|
|
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
|
|
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(
|
|
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 <
|
|
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(
|
|
165
|
-
this.receiveBuffer = this.receiveBuffer.slice(
|
|
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
|
-
*
|
|
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
|
|
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
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* @param {Object}
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
if (response.data) {
|
|
344
|
-
let data = response.data
|
|
412
|
+
_syncTransportToken (response, path, requestHeaders) {
|
|
413
|
+
if (response.status !== 200) return
|
|
345
414
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
//
|
|
416
|
-
|
|
417
|
-
response
|
|
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
|
|
520
|
+
data: this._decodeBody(response, options),
|
|
434
521
|
config: options
|
|
435
522
|
}
|
|
436
523
|
|
|
437
|
-
//
|
|
438
|
-
this.
|
|
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'],
|
|
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
|
|
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
|