appmesh 1.3.1 → 1.3.3
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 +8 -8
- package/dist/appmesh.esm.js +1 -1
- package/package.json +14 -2
- package/src/appmesh.js +333 -84
- package/src/appmesh_oauth.js +367 -0
- package/src/appmesh_tcp.js +20 -36
- package/src/{server.js → worker.js} +6 -29
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// appmesh_oauth.js - App Mesh client with Keycloak OAuth2 authentication support.
|
|
2
|
+
// Mirrors the Python SDK's AppMeshClientOAuth (client_http_oauth.py): tokens are obtained
|
|
3
|
+
// directly from Keycloak and the access token is used as the Bearer token for daemon calls.
|
|
4
|
+
import axios from "axios";
|
|
5
|
+
import AppMeshClient, { AppMeshError } from "./appmesh.js";
|
|
6
|
+
|
|
7
|
+
// OAuth2 scopes requesting identity claims so userinfo returns preferred_username/email
|
|
8
|
+
const DEFAULT_OAUTH_SCOPE = "openid profile email";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AppMeshClient with Keycloak as the identity provider.
|
|
12
|
+
*
|
|
13
|
+
* Authentication (login, token renewal, logout, userinfo) is performed directly against
|
|
14
|
+
* Keycloak's OpenID Connect endpoints; the Keycloak access token is then used as this
|
|
15
|
+
* client's token for App Mesh daemon API calls (RBAC is enforced by the daemon).
|
|
16
|
+
*/
|
|
17
|
+
class AppMeshClientOAuth extends AppMeshClient {
|
|
18
|
+
/**
|
|
19
|
+
* Initialize an App Mesh client with Keycloak support.
|
|
20
|
+
* @param {Object} oauth2 - Keycloak configuration for oauth2 authentication:
|
|
21
|
+
* - authServerUrl: Keycloak server URL (e.g. "https://keycloak.example.com/auth")
|
|
22
|
+
* - realm: Keycloak realm
|
|
23
|
+
* - clientId: Keycloak client ID
|
|
24
|
+
* - clientSecret: Keycloak client secret (optional, confidential clients only)
|
|
25
|
+
* @param {string} [baseURL] - App Mesh daemon URL (see AppMeshClient)
|
|
26
|
+
* @param {Object|false|null} [sslConfig] - SSL tri-state for the daemon connection (see AppMeshClient)
|
|
27
|
+
*/
|
|
28
|
+
constructor(oauth2, baseURL = undefined, sslConfig = null) {
|
|
29
|
+
super(baseURL, sslConfig);
|
|
30
|
+
|
|
31
|
+
if (!oauth2 || !oauth2.authServerUrl || !oauth2.realm || !oauth2.clientId) {
|
|
32
|
+
throw new AppMeshError(
|
|
33
|
+
"oauth2 config requires authServerUrl, realm and clientId",
|
|
34
|
+
400,
|
|
35
|
+
null,
|
|
36
|
+
"INVALID_OAUTH_CONFIG",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @type {Object} Keycloak configuration @private */
|
|
41
|
+
this._oauth2 = {
|
|
42
|
+
authServerUrl: String(oauth2.authServerUrl).replace(/\/+$/, ""),
|
|
43
|
+
realm: oauth2.realm,
|
|
44
|
+
clientId: oauth2.clientId,
|
|
45
|
+
clientSecret: oauth2.clientSecret || null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** @type {Object} Complete Keycloak token response (access_token, refresh_token, ...) @private */
|
|
49
|
+
this._oauthToken = {};
|
|
50
|
+
|
|
51
|
+
// Dedicated axios instance for Keycloak endpoints (the inherited this._client
|
|
52
|
+
// targets the App Mesh daemon and injects daemon-specific headers/cookies)
|
|
53
|
+
/** @private */
|
|
54
|
+
this._oauthClient = axios.create({
|
|
55
|
+
timeout: 60000,
|
|
56
|
+
validateStatus: () => true,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a Keycloak OpenID Connect endpoint URL for the configured realm.
|
|
62
|
+
* @param {string} name - Endpoint name (e.g. "token", "auth/device", "logout", "userinfo")
|
|
63
|
+
* @returns {string} Full endpoint URL
|
|
64
|
+
* @private
|
|
65
|
+
*/
|
|
66
|
+
_oauthEndpoint(name) {
|
|
67
|
+
return `${this._oauth2.authServerUrl}/realms/${encodeURIComponent(this._oauth2.realm)}/protocol/openid-connect/${name}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* POST x-www-form-urlencoded params to a Keycloak endpoint, adding client credentials.
|
|
72
|
+
* @param {string} endpoint - Endpoint name for _oauthEndpoint()
|
|
73
|
+
* @param {Object} params - Form fields (null/undefined values are skipped)
|
|
74
|
+
* @returns {Promise<any>} Raw axios response (any status)
|
|
75
|
+
* @private
|
|
76
|
+
*/
|
|
77
|
+
async _oauthPost(endpoint, params) {
|
|
78
|
+
const form = new URLSearchParams();
|
|
79
|
+
form.append("client_id", this._oauth2.clientId);
|
|
80
|
+
if (this._oauth2.clientSecret)
|
|
81
|
+
form.append("client_secret", this._oauth2.clientSecret);
|
|
82
|
+
for (const [key, value] of Object.entries(params)) {
|
|
83
|
+
// Values are appended as-is (strings): TOTP codes keep leading zeros
|
|
84
|
+
if (value !== null && value !== undefined)
|
|
85
|
+
form.append(key, String(value));
|
|
86
|
+
}
|
|
87
|
+
return this._oauthClient.post(this._oauthEndpoint(endpoint), form);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extract the OAuth2 "error" code from a Keycloak error response body.
|
|
92
|
+
* @param {any} data - Response body
|
|
93
|
+
* @returns {string} OAuth2 error code or ""
|
|
94
|
+
* @private
|
|
95
|
+
*/
|
|
96
|
+
static _oauthErrorCode(data) {
|
|
97
|
+
if (typeof data === "string") {
|
|
98
|
+
try {
|
|
99
|
+
data = JSON.parse(data);
|
|
100
|
+
} catch (_) {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return data && typeof data === "object" && typeof data.error === "string"
|
|
105
|
+
? data.error
|
|
106
|
+
: "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Store a full Keycloak token response and propagate the access token to the base client.
|
|
111
|
+
* @param {Object} tokenResponse - Complete Keycloak token endpoint response
|
|
112
|
+
* @private
|
|
113
|
+
*/
|
|
114
|
+
_storeOauthToken(tokenResponse) {
|
|
115
|
+
this._oauthToken = tokenResponse || {};
|
|
116
|
+
this._handleTokenUpdate(this._oauthToken.access_token || null);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Login with username and password using the OAuth2 Direct Access Grant (Resource
|
|
121
|
+
* Owner Password Credentials) against Keycloak.
|
|
122
|
+
* @param {string} username - The name of the user
|
|
123
|
+
* @param {string} password - The password of the user
|
|
124
|
+
* @param {string} [totpCode] - TOTP code if enabled for the user. Passed verbatim as a
|
|
125
|
+
* string: leading zeros matter (e.g. "012345")
|
|
126
|
+
* @param {string|number} [tokenExpire] - Ignored by the Keycloak login flow
|
|
127
|
+
* @param {string} [audience] - Ignored by the Keycloak login flow
|
|
128
|
+
* @returns {Promise<void>} Resolves when login succeeds
|
|
129
|
+
* @throws {AppMeshError} If authentication fails
|
|
130
|
+
*/
|
|
131
|
+
async login(
|
|
132
|
+
username,
|
|
133
|
+
password,
|
|
134
|
+
totpCode = null,
|
|
135
|
+
tokenExpire = null,
|
|
136
|
+
audience = null,
|
|
137
|
+
) {
|
|
138
|
+
if (tokenExpire || audience) {
|
|
139
|
+
console.warn(
|
|
140
|
+
"tokenExpire and audience are ignored by the Keycloak login flow",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!username || !password) {
|
|
144
|
+
throw new AppMeshError(
|
|
145
|
+
"Username and password are required",
|
|
146
|
+
400,
|
|
147
|
+
null,
|
|
148
|
+
"INVALID_CREDENTIALS",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const response = await this._oauthPost("token", {
|
|
153
|
+
grant_type: "password",
|
|
154
|
+
username,
|
|
155
|
+
password,
|
|
156
|
+
scope: DEFAULT_OAUTH_SCOPE,
|
|
157
|
+
// Pass TOTP as-is (string): a numeric conversion would strip leading zeros
|
|
158
|
+
totp: totpCode ? String(totpCode) : null,
|
|
159
|
+
});
|
|
160
|
+
if (response.status !== 200) {
|
|
161
|
+
throw new AppMeshError(
|
|
162
|
+
`Keycloak login failed: ${this._extractErrorMessage(response.data)}`,
|
|
163
|
+
response.status,
|
|
164
|
+
response.data,
|
|
165
|
+
"OAUTH_LOGIN_FAILED",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
this._storeOauthToken(response.data);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Login via the OAuth 2.0 Device Authorization Grant (RFC 8628).
|
|
173
|
+
*
|
|
174
|
+
* For browserless/input-constrained environments: the user opens
|
|
175
|
+
* `verification_uri_complete` (or `verification_uri` + `user_code`) on another device,
|
|
176
|
+
* and this call polls the token endpoint until approval, denial, or expiry.
|
|
177
|
+
*
|
|
178
|
+
* Requires "OAuth 2.0 Device Authorization Grant" enabled on the Keycloak client.
|
|
179
|
+
*
|
|
180
|
+
* @param {Function} [onPrompt] - Callback receiving the device authorization response
|
|
181
|
+
* (keys per RFC 8628 §3.2: `user_code`, `verification_uri`, `verification_uri_complete`,
|
|
182
|
+
* `expires_in`, `interval`) to present the instructions to the user. Defaults to
|
|
183
|
+
* printing them to the console.
|
|
184
|
+
* @param {string} [scope] - OAuth2 scopes to request
|
|
185
|
+
* @returns {Promise<void>} Resolves once the user approved the request
|
|
186
|
+
* @throws {AppMeshError} When the user denies the request, the device code expires,
|
|
187
|
+
* or the token request fails for any other reason
|
|
188
|
+
*/
|
|
189
|
+
async login_device_flow(onPrompt = null, scope = DEFAULT_OAUTH_SCOPE) {
|
|
190
|
+
const deviceResponse = await this._oauthPost("auth/device", { scope });
|
|
191
|
+
if (deviceResponse.status !== 200) {
|
|
192
|
+
throw new AppMeshError(
|
|
193
|
+
`Device authorization request failed: ${this._extractErrorMessage(deviceResponse.data)}`,
|
|
194
|
+
deviceResponse.status,
|
|
195
|
+
deviceResponse.data,
|
|
196
|
+
"OAUTH_DEVICE_FLOW_FAILED",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const device = deviceResponse.data;
|
|
200
|
+
|
|
201
|
+
if (onPrompt) {
|
|
202
|
+
onPrompt(device);
|
|
203
|
+
} else {
|
|
204
|
+
const uri = device.verification_uri_complete || device.verification_uri;
|
|
205
|
+
console.log(
|
|
206
|
+
`To sign in, open ${uri} and enter code: ${device.user_code}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// RFC 8628 §3.5: poll no faster than "interval", stop once the device code expires
|
|
211
|
+
let interval = parseInt(device.interval, 10) || 5;
|
|
212
|
+
const deadline =
|
|
213
|
+
Date.now() + (parseInt(device.expires_in, 10) || 600) * 1000;
|
|
214
|
+
|
|
215
|
+
for (;;) {
|
|
216
|
+
const remaining = (deadline - Date.now()) / 1000;
|
|
217
|
+
if (remaining <= 0) {
|
|
218
|
+
throw new AppMeshError(
|
|
219
|
+
"Device authorization expired before the user approved the request",
|
|
220
|
+
null,
|
|
221
|
+
null,
|
|
222
|
+
"OAUTH_DEVICE_FLOW_EXPIRED",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
await this._sleep(Math.min(interval, remaining));
|
|
226
|
+
|
|
227
|
+
const response = await this._oauthPost("token", {
|
|
228
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
229
|
+
device_code: device.device_code,
|
|
230
|
+
});
|
|
231
|
+
if (response.status === 200) {
|
|
232
|
+
this._storeOauthToken(response.data);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const error = AppMeshClientOAuth._oauthErrorCode(response.data);
|
|
236
|
+
if (error === "authorization_pending") {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (error === "slow_down") {
|
|
240
|
+
interval += 5; // RFC 8628 §3.5: back off by 5 seconds
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
throw new AppMeshError(
|
|
244
|
+
`Device authorization failed: ${error || this._extractErrorMessage(response.data)}`,
|
|
245
|
+
response.status,
|
|
246
|
+
response.data,
|
|
247
|
+
"OAUTH_DEVICE_FLOW_FAILED",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Sleep helper used by the device-flow polling loop.
|
|
254
|
+
* @param {number} seconds - Duration in seconds
|
|
255
|
+
* @returns {Promise<void>}
|
|
256
|
+
* @protected
|
|
257
|
+
*/
|
|
258
|
+
_sleep(seconds) {
|
|
259
|
+
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Renew the current Keycloak token using the refresh token.
|
|
264
|
+
* Keycloak rotates the refresh token: the complete new token response is stored.
|
|
265
|
+
* @param {string|number} [tokenExpire] - Ignored by the Keycloak refresh flow
|
|
266
|
+
* @returns {Promise<void>}
|
|
267
|
+
* @throws {AppMeshError} If no refresh token is available or the renewal fails
|
|
268
|
+
*/
|
|
269
|
+
async renew_token(tokenExpire = null) {
|
|
270
|
+
// eslint-disable-line no-unused-vars
|
|
271
|
+
const refreshToken = this._oauthToken && this._oauthToken.refresh_token;
|
|
272
|
+
if (!refreshToken) {
|
|
273
|
+
throw new AppMeshError(
|
|
274
|
+
"No Keycloak refresh token available to renew",
|
|
275
|
+
null,
|
|
276
|
+
null,
|
|
277
|
+
"OAUTH_NO_REFRESH_TOKEN",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const response = await this._oauthPost("token", {
|
|
282
|
+
grant_type: "refresh_token",
|
|
283
|
+
refresh_token: refreshToken,
|
|
284
|
+
});
|
|
285
|
+
if (response.status !== 200) {
|
|
286
|
+
throw new AppMeshError(
|
|
287
|
+
`Keycloak token renewal failed: ${this._extractErrorMessage(response.data)}`,
|
|
288
|
+
response.status,
|
|
289
|
+
response.data,
|
|
290
|
+
"OAUTH_RENEW_FAILED",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
// Keycloak rotates refresh tokens: replace the whole stored token response
|
|
294
|
+
this._storeOauthToken(response.data);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Log out of the current session from Keycloak and the App Mesh daemon.
|
|
299
|
+
* The daemon logoff (super.logout) runs BEFORE the local token is cleared so it can
|
|
300
|
+
* still authenticate; the Keycloak session is revoked via the refresh token.
|
|
301
|
+
* @returns {Promise<boolean>} true when the Keycloak session was revoked
|
|
302
|
+
*/
|
|
303
|
+
async logout() {
|
|
304
|
+
let result = false;
|
|
305
|
+
const refreshToken = this._oauthToken && this._oauthToken.refresh_token;
|
|
306
|
+
if (refreshToken) {
|
|
307
|
+
try {
|
|
308
|
+
const response = await this._oauthPost("logout", {
|
|
309
|
+
refresh_token: refreshToken,
|
|
310
|
+
});
|
|
311
|
+
if (response.status >= 200 && response.status < 300) {
|
|
312
|
+
result = true;
|
|
313
|
+
} else {
|
|
314
|
+
console.warn(
|
|
315
|
+
`Failed to logout from Keycloak: ${this._extractErrorMessage(response.data)}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
} catch (error) {
|
|
319
|
+
console.warn(`Failed to logout from Keycloak: ${error.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Daemon-side logoff before clearing the local token (it still needs the token)
|
|
324
|
+
await super.logout();
|
|
325
|
+
this._oauthToken = {};
|
|
326
|
+
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get Keycloak OIDC userinfo for the current access token, directly from Keycloak.
|
|
332
|
+
* Unlike get_current_user() (inherited), which asks the App Mesh daemon
|
|
333
|
+
* /appmesh/user/self, this queries the Keycloak userinfo endpoint without the daemon.
|
|
334
|
+
* @returns {Promise<Object>} Keycloak userinfo (OIDC claims such as `sub`,
|
|
335
|
+
* `preferred_username`, `email`)
|
|
336
|
+
* @throws {AppMeshError} If no access token is available or the request fails
|
|
337
|
+
*/
|
|
338
|
+
async get_oauth_userinfo() {
|
|
339
|
+
const accessToken = this._oauthToken && this._oauthToken.access_token;
|
|
340
|
+
if (!accessToken) {
|
|
341
|
+
throw new AppMeshError(
|
|
342
|
+
"No Keycloak access token available",
|
|
343
|
+
null,
|
|
344
|
+
null,
|
|
345
|
+
"OAUTH_NO_ACCESS_TOKEN",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const response = await this._oauthClient.get(
|
|
349
|
+
this._oauthEndpoint("userinfo"),
|
|
350
|
+
{
|
|
351
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
if (response.status !== 200) {
|
|
355
|
+
throw new AppMeshError(
|
|
356
|
+
`Keycloak userinfo request failed: ${this._extractErrorMessage(response.data)}`,
|
|
357
|
+
response.status,
|
|
358
|
+
response.data,
|
|
359
|
+
"OAUTH_USERINFO_FAILED",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return response.data;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export { AppMeshClientOAuth };
|
|
367
|
+
export default AppMeshClientOAuth;
|
package/src/appmesh_tcp.js
CHANGED
|
@@ -31,14 +31,7 @@ const LOGOFF_PATH = '/appmesh/self/logoff'
|
|
|
31
31
|
/**
|
|
32
32
|
* TCP transport for secure TLS connections and framed App Mesh messages.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
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`.
|
|
34
|
+
* @param {Object|false|null} [sslConfig=null] - TLS config; same tri-state as AppMeshClient (null=default CA, object=custom ca/cert/key, false=insecure).
|
|
42
35
|
*/
|
|
43
36
|
class TCPTransport {
|
|
44
37
|
/**
|
|
@@ -250,13 +243,15 @@ class RequestMessage {
|
|
|
250
243
|
* Serialize message to msgpack format
|
|
251
244
|
*/
|
|
252
245
|
serialize () {
|
|
246
|
+
// Coerce header/query values to strings; the daemon expects map<string,string>.
|
|
247
|
+
const strMap = (o) => Object.fromEntries(Object.entries(o || {}).map(([k, v]) => [k, String(v)]))
|
|
253
248
|
const data = {
|
|
254
249
|
uuid: this.uuid,
|
|
255
250
|
http_method: this.httpMethod,
|
|
256
251
|
request_uri: this.requestUri,
|
|
257
252
|
client_addr: this.clientAddr,
|
|
258
|
-
headers: this.headers,
|
|
259
|
-
query: this.query,
|
|
253
|
+
headers: strMap(this.headers),
|
|
254
|
+
query: strMap(this.query),
|
|
260
255
|
body: this.body
|
|
261
256
|
}
|
|
262
257
|
return msgpack.encode(data)
|
|
@@ -331,16 +326,7 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
331
326
|
* `new AppMeshClientTCP(sslConfig, {host, port})` — the address may be an explicit
|
|
332
327
|
* `{host, port}` object in either position.
|
|
333
328
|
*
|
|
334
|
-
*
|
|
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)
|
|
340
|
-
* @param {Buffer|string} [sslConfig.ca] - CA certificate
|
|
341
|
-
* @param {Buffer|string} [sslConfig.cert] - Client certificate
|
|
342
|
-
* @param {Buffer|string} [sslConfig.key] - Client key
|
|
343
|
-
* @param {boolean} [sslConfig.rejectUnauthorized=true] - Whether to verify SSL
|
|
329
|
+
* @param {Object|false|null} [sslConfig=null] - TLS config; same tri-state as AppMeshClient (null=default CA, object=custom ca/cert/key, false=insecure).
|
|
344
330
|
* @param {Array<string, number>|{host: string, port: number}} [tcpAddress=['127.0.0.1', 6059]] - TCP server address
|
|
345
331
|
*/
|
|
346
332
|
constructor (sslConfig = null, tcpAddress = ['127.0.0.1', 6059]) {
|
|
@@ -594,12 +580,13 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
594
580
|
* Download a file through the TCP file-socket side channel.
|
|
595
581
|
*
|
|
596
582
|
* @param {string} filePath - Remote file path
|
|
597
|
-
* @param {string} localFile - Local destination path
|
|
583
|
+
* @param {string} [localFile=null] - Local destination path; defaults to the basename of filePath
|
|
598
584
|
* @param {boolean} [applyAttrs=true] - Apply returned mode and best-effort owner/group metadata
|
|
599
585
|
* on non-Windows Node.js hosts
|
|
600
586
|
* @override
|
|
601
587
|
*/
|
|
602
|
-
async download_file (filePath, localFile, applyAttrs = true) {
|
|
588
|
+
async download_file (filePath, localFile = null, applyAttrs = true) {
|
|
589
|
+
if (!localFile) localFile = filePath.split(/[\\/]/).pop()
|
|
603
590
|
const headers = {
|
|
604
591
|
[HTTP_HEADER_KEY_X_FILE_PATH]: encodeURIComponent(filePath),
|
|
605
592
|
[HTTP_HEADER_KEY_X_RECV_FILE_SOCKET]: 'true'
|
|
@@ -631,7 +618,12 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
631
618
|
writeStream.write(chunk)
|
|
632
619
|
}
|
|
633
620
|
} finally {
|
|
634
|
-
|
|
621
|
+
// Await the flush so the file is complete on disk before this resolves.
|
|
622
|
+
await new Promise((resolve, reject) => {
|
|
623
|
+
writeStream.once('finish', resolve)
|
|
624
|
+
writeStream.once('error', reject)
|
|
625
|
+
writeStream.end()
|
|
626
|
+
})
|
|
635
627
|
}
|
|
636
628
|
|
|
637
629
|
// Apply file attributes on Unix systems
|
|
@@ -660,12 +652,13 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
660
652
|
* Upload a file through the TCP file-socket side channel.
|
|
661
653
|
*
|
|
662
654
|
* @param {string} localFile - Local file path
|
|
663
|
-
* @param {string} filePath - Remote destination path
|
|
655
|
+
* @param {string} [filePath=null] - Remote destination path; defaults to the basename of localFile
|
|
664
656
|
* @param {boolean} [applyAttrs=true] - Send local mode/uid/gid metadata so the server can
|
|
665
657
|
* recreate permissions and ownership when supported
|
|
666
658
|
* @override
|
|
667
659
|
*/
|
|
668
|
-
async upload_file (localFile, filePath, applyAttrs = true) {
|
|
660
|
+
async upload_file (localFile, filePath = null, applyAttrs = true) {
|
|
661
|
+
if (!filePath) filePath = localFile.split(/[\\/]/).pop()
|
|
669
662
|
if (!fs.existsSync(localFile)) {
|
|
670
663
|
throw new Error(`Local file not found: ${localFile}`)
|
|
671
664
|
}
|
|
@@ -791,22 +784,13 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
791
784
|
}
|
|
792
785
|
|
|
793
786
|
/**
|
|
794
|
-
*
|
|
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
|
-
*
|
|
787
|
+
* Wait for an async run to finish over TCP, streaming incremental output.
|
|
803
788
|
* @param {AppRun} run - AppRun object
|
|
804
789
|
* @param {Function} [stdoutHandler] - Stdout handler callback(data, position)
|
|
805
790
|
* @param {number} [timeout=0] - Max wait time in seconds (0 = unlimited)
|
|
806
791
|
* @returns {Promise<number|null>} Exit code, or null on timeout
|
|
807
792
|
* @throws {AppRemovedError} If the app was removed before its exit was observed.
|
|
808
|
-
* @throws {TransportDisconnectedError} If the
|
|
809
|
-
* or the daemon delivered an unparseable exit code.
|
|
793
|
+
* @throws {TransportDisconnectedError} If the transport disconnected while waiting, or the daemon delivered an unparseable exit code.
|
|
810
794
|
*/
|
|
811
795
|
async wait_for_async_run (run, stdoutHandler, timeout = 0) {
|
|
812
796
|
if (!run || !run.appName) return null
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// worker.js - App Mesh Worker SDK for Node.js
|
|
2
2
|
// This is a Node.js-only module - not included in browser builds
|
|
3
3
|
|
|
4
4
|
// Environment check - only works in Node.js
|
|
@@ -57,7 +57,7 @@ const _DEFER_CLIENT = Symbol('appmesh.deferClient')
|
|
|
57
57
|
*
|
|
58
58
|
* @example
|
|
59
59
|
* // HTTP Worker Example
|
|
60
|
-
* import { AppMeshWorker } from 'appmesh/
|
|
60
|
+
* import { AppMeshWorker } from 'appmesh/worker';
|
|
61
61
|
*
|
|
62
62
|
* const worker = new AppMeshWorker();
|
|
63
63
|
* const payload = await worker.fetch_task();
|
|
@@ -66,7 +66,7 @@ const _DEFER_CLIENT = Symbol('appmesh.deferClient')
|
|
|
66
66
|
*
|
|
67
67
|
* @example
|
|
68
68
|
* // TCP Worker Example
|
|
69
|
-
* import { AppMeshWorkerTCP } from 'appmesh/
|
|
69
|
+
* import { AppMeshWorkerTCP } from 'appmesh/worker';
|
|
70
70
|
*
|
|
71
71
|
* const worker = new AppMeshWorkerTCP();
|
|
72
72
|
* try {
|
|
@@ -176,11 +176,6 @@ class AppMeshWorker {
|
|
|
176
176
|
* @throws {ProcessSupersededError} The daemon reported HTTP 412 — this process key was
|
|
177
177
|
* superseded by a newer process instance; the caller should stop serving (an app
|
|
178
178
|
* entry point typically catches this and exits).
|
|
179
|
-
*
|
|
180
|
-
* @example
|
|
181
|
-
* const worker = new AppMeshWorker();
|
|
182
|
-
* const payload = await worker.fetch_task();
|
|
183
|
-
* console.log('Received payload:', payload);
|
|
184
179
|
*/
|
|
185
180
|
async fetch_task () {
|
|
186
181
|
const { processKey, appName } = this._getRuntimeEnv()
|
|
@@ -244,11 +239,6 @@ class AppMeshWorker {
|
|
|
244
239
|
* @param {string|Buffer} result - Result payload to be delivered back to the client as-is
|
|
245
240
|
* @returns {Promise<void>}
|
|
246
241
|
* @throws {Error} If the task return fails
|
|
247
|
-
*
|
|
248
|
-
* @example
|
|
249
|
-
* const worker = new AppMeshWorker();
|
|
250
|
-
* const result = { status: 'success', data: processedData };
|
|
251
|
-
* await worker.send_task_result(JSON.stringify(result));
|
|
252
242
|
*/
|
|
253
243
|
async send_task_result (result) {
|
|
254
244
|
const { processKey, appName } = this._getRuntimeEnv()
|
|
@@ -292,7 +282,7 @@ class AppMeshWorker {
|
|
|
292
282
|
* @extends AppMeshWorker
|
|
293
283
|
*
|
|
294
284
|
* @example
|
|
295
|
-
* import { AppMeshWorkerTCP } from 'appmesh/
|
|
285
|
+
* import { AppMeshWorkerTCP } from 'appmesh/worker';
|
|
296
286
|
*
|
|
297
287
|
* const worker = new AppMeshWorkerTCP();
|
|
298
288
|
* try {
|
|
@@ -319,12 +309,7 @@ class AppMeshWorkerTCP extends AppMeshWorker {
|
|
|
319
309
|
* @param {Array<string, number>|{host: string, port: number}} [tcpAddress=['127.0.0.1', 6059]] - TCP server address
|
|
320
310
|
* @param {Object} [options={}] - Additional options
|
|
321
311
|
* @param {Object} [options.logger=console] - Logger instance
|
|
322
|
-
* @param {AppMeshClientTCP} [options.client] -
|
|
323
|
-
* When supplied, `sslConfig` and `tcpAddress` are ignored and the worker shares the caller's
|
|
324
|
-
* client (and therefore its token-refresh state). Use this when a single process needs both
|
|
325
|
-
* client and worker roles to avoid two independent `/token/renew` loops fighting each other —
|
|
326
|
-
* the daemon blacklists the previous token on every renew, so the slower refresher gets
|
|
327
|
-
* 401 "Token has been revoked".
|
|
312
|
+
* @param {AppMeshClientTCP} [options.client] - Pre-built client to reuse (shares token-refresh state); see AppMeshWorker for why. When set, sslConfig/tcpAddress are ignored.
|
|
328
313
|
*
|
|
329
314
|
* @example
|
|
330
315
|
* import fs from 'fs';
|
|
@@ -392,14 +377,6 @@ class AppMeshWorkerTCP extends AppMeshWorker {
|
|
|
392
377
|
/**
|
|
393
378
|
* Close the TCP connection and release resources.
|
|
394
379
|
* Should be called when done using the server.
|
|
395
|
-
*
|
|
396
|
-
* @example
|
|
397
|
-
* const worker = new AppMeshWorkerTCP();
|
|
398
|
-
* try {
|
|
399
|
-
* // Use worker...
|
|
400
|
-
* } finally {
|
|
401
|
-
* worker.close();
|
|
402
|
-
* }
|
|
403
380
|
*/
|
|
404
381
|
close () {
|
|
405
382
|
if (this._client && typeof this._client.close === 'function') {
|
|
@@ -417,7 +394,7 @@ class AppMeshWorkerTCP extends AppMeshWorker {
|
|
|
417
394
|
* @returns {Promise<any>} Result from callback
|
|
418
395
|
*
|
|
419
396
|
* @example
|
|
420
|
-
* import { withServer, AppMeshWorkerTCP } from 'appmesh/
|
|
397
|
+
* import { withServer, AppMeshWorkerTCP } from 'appmesh/worker';
|
|
421
398
|
*
|
|
422
399
|
* await withServer(
|
|
423
400
|
* () => new AppMeshWorkerTCP(),
|