@evomap/evolver-adapter-public 2.0.0-beta.2 → 2.0.0-beta.22
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/dist/antiAbuseTelemetry.js +2 -1
- package/dist/auth/credentialStore.d.ts +90 -3
- package/dist/auth/credentialStore.js +1096 -10
- package/dist/auth/legacyShim.d.ts +2 -2
- package/dist/auth/legacyShim.js +2 -2
- package/dist/auth/oauthDeviceToken.d.ts +9 -6
- package/dist/auth/oauthDeviceToken.js +71 -18
- package/dist/auth/oauthHttpTransport.d.ts +4 -0
- package/dist/auth/oauthHttpTransport.js +66 -15
- package/dist/auth/windowsPowerShell.d.ts +3 -0
- package/dist/auth/windowsPowerShell.js +91 -0
- package/dist/hubCapability.d.ts +35 -9
- package/dist/hubCapability.js +593 -54
- package/dist/hubFetch.d.ts +47 -14
- package/dist/hubFetch.js +374 -79
- package/dist/hubReuse.d.ts +41 -0
- package/dist/hubReuse.js +328 -33
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/learningPacketFeedback.d.ts +68 -0
- package/dist/learningPacketFeedback.js +104 -0
- package/dist/learningPacketSink.d.ts +48 -0
- package/dist/learningPacketSink.js +194 -0
- package/dist/wireMap.d.ts +3 -1
- package/dist/wireMap.js +29 -3
- package/package.json +9 -2
package/dist/hubFetch.js
CHANGED
|
@@ -5,6 +5,29 @@ export const HUB_ERROR_TEXT_MAX_BYTES = 8 * 1024;
|
|
|
5
5
|
export const HUB_JSON_TEXT_MAX_BYTES = 4 * 1024 * 1024;
|
|
6
6
|
export const HUB_UNREACHABLE_BACKOFF_BASE_MS = 60_000;
|
|
7
7
|
export const HUB_UNREACHABLE_BACKOFF_MAX_MS = 10 * 60_000;
|
|
8
|
+
export const HUB_GENERAL_TIMEOUT_MS = 15_000;
|
|
9
|
+
export const HUB_SEARCH_TIMEOUT_MS = 8_000;
|
|
10
|
+
export const HUB_HEARTBEAT_TIMEOUT_MS = 10_000;
|
|
11
|
+
export const HUB_EVENT_POLL_TIMEOUT_MS = 60_000;
|
|
12
|
+
export const HUB_HELLO_TIMEOUT_MS = 15_000;
|
|
13
|
+
const HUB_OPERATION_TIMEOUT_KEYS = {
|
|
14
|
+
general: 'generalMs',
|
|
15
|
+
search: 'searchMs',
|
|
16
|
+
heartbeat: 'heartbeatMs',
|
|
17
|
+
poll: 'pollMs',
|
|
18
|
+
hello: 'helloMs',
|
|
19
|
+
};
|
|
20
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 20_000;
|
|
21
|
+
const DEFAULT_DEADLINE_SCHEDULER = {
|
|
22
|
+
set(callback, delayMs) {
|
|
23
|
+
const timer = setTimeout(callback, delayMs);
|
|
24
|
+
timer.unref?.();
|
|
25
|
+
return timer;
|
|
26
|
+
},
|
|
27
|
+
clear(handle) {
|
|
28
|
+
clearTimeout(handle);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
8
31
|
export class AuthError extends Error {
|
|
9
32
|
status;
|
|
10
33
|
body;
|
|
@@ -21,10 +44,12 @@ export class AuthError extends Error {
|
|
|
21
44
|
export class HubClientError extends Error {
|
|
22
45
|
status;
|
|
23
46
|
body;
|
|
24
|
-
|
|
47
|
+
retryAfterMs;
|
|
48
|
+
constructor(status, body, retryAfterMs) {
|
|
25
49
|
super(`hub ${status}`);
|
|
26
50
|
this.status = status;
|
|
27
51
|
this.body = body;
|
|
52
|
+
this.retryAfterMs = retryAfterMs;
|
|
28
53
|
this.name = 'HubClientError';
|
|
29
54
|
}
|
|
30
55
|
}
|
|
@@ -40,79 +65,285 @@ export class HubUnreachableError extends Error {
|
|
|
40
65
|
return this.details.retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS;
|
|
41
66
|
}
|
|
42
67
|
}
|
|
68
|
+
const PROTECTED_REQUEST_HEADERS = new Set([
|
|
69
|
+
'authorization',
|
|
70
|
+
'content-type',
|
|
71
|
+
'x-evomap-node-secret-version',
|
|
72
|
+
'x-evomap-signature',
|
|
73
|
+
'x-node-secret',
|
|
74
|
+
]);
|
|
75
|
+
const LEGACY_BEARER_POST_PATHS = new Set([
|
|
76
|
+
'/a2a/hello',
|
|
77
|
+
'/a2a/publish',
|
|
78
|
+
'/a2a/validate',
|
|
79
|
+
'/a2a/fetch',
|
|
80
|
+
'/a2a/events/poll',
|
|
81
|
+
'/a2a/mailbox/outbound',
|
|
82
|
+
]);
|
|
83
|
+
function requestHeaderName(headers, name) {
|
|
84
|
+
const normalized = name.toLowerCase();
|
|
85
|
+
return Object.keys(headers).find((headerName) => headerName.toLowerCase() === normalized);
|
|
86
|
+
}
|
|
87
|
+
function setLegacyBearerFallback(headers, nodeSecret) {
|
|
88
|
+
const existingName = requestHeaderName(headers, 'authorization');
|
|
89
|
+
if (existingName !== undefined && headers[existingName]?.trim())
|
|
90
|
+
return false;
|
|
91
|
+
if (existingName !== undefined)
|
|
92
|
+
delete headers[existingName];
|
|
93
|
+
headers['authorization'] = `Bearer ${nodeSecret}`;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
function legacyNodeSecret(value) {
|
|
97
|
+
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value) ? value : undefined;
|
|
98
|
+
}
|
|
99
|
+
function isGepEnvelope(body) {
|
|
100
|
+
return body?.['protocol'] === 'gep-a2a'
|
|
101
|
+
&& body['protocol_version'] === '1.0.0'
|
|
102
|
+
&& typeof body['message_type'] === 'string'
|
|
103
|
+
&& body['message_type'].trim().length > 0
|
|
104
|
+
&& typeof body['message_id'] === 'string'
|
|
105
|
+
&& body['message_id'].trim().length > 0
|
|
106
|
+
&& typeof body['timestamp'] === 'string'
|
|
107
|
+
&& Number.isFinite(Date.parse(body['timestamp']))
|
|
108
|
+
&& Object.prototype.hasOwnProperty.call(body, 'payload')
|
|
109
|
+
&& body['payload'] !== undefined;
|
|
110
|
+
}
|
|
111
|
+
function usesLegacyBearerForPost(method, path, body) {
|
|
112
|
+
return method.toUpperCase() === 'POST'
|
|
113
|
+
&& (LEGACY_BEARER_POST_PATHS.has(path) || isGepEnvelope(body));
|
|
114
|
+
}
|
|
115
|
+
function mergeRequestHeaders(requestHeaders, signedHeaders) {
|
|
116
|
+
const signedNames = new Set(Object.keys(signedHeaders ?? {}).map((name) => name.toLowerCase()));
|
|
117
|
+
const headers = {};
|
|
118
|
+
for (const [name, value] of Object.entries(requestHeaders ?? {})) {
|
|
119
|
+
const normalized = name.toLowerCase();
|
|
120
|
+
if (PROTECTED_REQUEST_HEADERS.has(normalized) || signedNames.has(normalized))
|
|
121
|
+
continue;
|
|
122
|
+
if (normalized === 'idempotency-key') {
|
|
123
|
+
const trimmed = value.trim();
|
|
124
|
+
if (!trimmed)
|
|
125
|
+
throw new Error('idempotency-key must be non-empty');
|
|
126
|
+
headers[normalized] = trimmed;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
headers[normalized] = value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
headers['content-type'] = 'application/json';
|
|
133
|
+
return { ...headers, ...signedHeaders };
|
|
134
|
+
}
|
|
43
135
|
/**
|
|
44
|
-
* 公版 hub HTTP 客户端(M6-6). 每请求经 AuthProvider 取凭证:
|
|
45
|
-
* 走 **Authorization: Bearer <node_secret>**
|
|
46
|
-
* sender_id 是标识非凭证, 留 query/body.
|
|
136
|
+
* 公版 hub HTTP 客户端(M6-6). 每请求经 AuthProvider 取凭证: legacy node_secret 对 GET 与 strict
|
|
137
|
+
* GEP envelope POST 走 **Authorization: Bearer <node_secret>** 头,绝不进入 query 或 envelope body;
|
|
138
|
+
* 其余兼容 REST POST 保留既有 body contract。sender_id 是标识非凭证, 留 query/body.
|
|
47
139
|
* 401/403→AuthError(reauth), 4xx→HubClientError(终态), 5xx→重试.
|
|
48
140
|
* 非 JSON Hub 响应(WAF/HTML/captive portal/gateway text)→HubUnreachableError, 不触发 auth recovery.
|
|
49
141
|
*/
|
|
50
142
|
export class HubFetch {
|
|
51
143
|
deps;
|
|
144
|
+
operationTimeouts;
|
|
145
|
+
deadlineScheduler;
|
|
52
146
|
constructor(deps) {
|
|
53
147
|
this.deps = deps;
|
|
148
|
+
this.operationTimeouts = resolveHubOperationTimeouts(deps.env ?? process.env, deps.operationTimeouts);
|
|
149
|
+
this.deadlineScheduler = deps.deadlineScheduler ?? DEFAULT_DEADLINE_SCHEDULER;
|
|
54
150
|
}
|
|
55
|
-
async call(method, path, bodyObj, query) {
|
|
151
|
+
async call(method, path, bodyObj, query, requestHeaders) {
|
|
152
|
+
const operation = hubOperationForRequest(path, bodyObj);
|
|
56
153
|
const draft = bodyObj !== undefined ? JSON.stringify(bodyObj) : '';
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
assertHubUrlSecure(url); // request-level scheme guard (defense in depth): even a misconfigured injected fetchFn cannot egress in plaintext
|
|
62
|
-
let body;
|
|
63
|
-
const headers = { 'content-type': 'application/json', ...signed.headers };
|
|
64
|
-
if (method === 'GET') {
|
|
65
|
-
const qs = new URLSearchParams();
|
|
66
|
-
if (sender)
|
|
67
|
-
qs.set('sender_id', sender); // identifier, not a credential — query is fine
|
|
68
|
-
if (query)
|
|
69
|
-
for (const [k, v] of Object.entries(query))
|
|
70
|
-
if (v !== undefined)
|
|
71
|
-
qs.set(k, String(v)); // non-credential GET params (e.g. semantic-search q)
|
|
72
|
-
// #8: credentials must NOT go in the query (leaks to access logs / proxies even over https).
|
|
73
|
-
// node_secret travels via Authorization: Bearer; the hub reads it there, never from the query.
|
|
74
|
-
const nodeSecret = creds['node_secret'];
|
|
75
|
-
if (nodeSecret !== undefined && headers['authorization'] === undefined)
|
|
76
|
-
headers['authorization'] = `Bearer ${String(nodeSecret)}`;
|
|
77
|
-
const q = qs.toString();
|
|
78
|
-
if (q)
|
|
79
|
-
url += `?${q}`;
|
|
154
|
+
const authDeadline = createHubDeadline(this.deadlineScheduler, method, path, operation, this.deps.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS);
|
|
155
|
+
let signed;
|
|
156
|
+
try {
|
|
157
|
+
signed = await awaitWithAbort(this.deps.auth.authenticate({ method, path, ...(draft ? { body: draft } : {}), signal: authDeadline.signal }), authDeadline.signal);
|
|
80
158
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
if (path === '/a2a/mailbox/outbound' && sender) {
|
|
90
|
-
const qs = new URLSearchParams({ sender_id: sender });
|
|
91
|
-
url += `?${qs.toString()}`;
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (isAuthTransportTimeout(error)) {
|
|
161
|
+
throw new HubUnreachableError('hub authentication timed out', {
|
|
162
|
+
context: `${method} ${path}`,
|
|
163
|
+
retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
164
|
+
operation,
|
|
165
|
+
});
|
|
92
166
|
}
|
|
93
|
-
|
|
167
|
+
throw error;
|
|
94
168
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
res = await this.deps.fetchFn(url, { method, headers, ...(body ? { body } : {}) });
|
|
169
|
+
finally {
|
|
170
|
+
authDeadline.dispose();
|
|
98
171
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
172
|
+
const timeoutMs = this.operationTimeouts[HUB_OPERATION_TIMEOUT_KEYS[operation]];
|
|
173
|
+
const deadline = createHubDeadline(this.deadlineScheduler, method, path, operation, timeoutMs);
|
|
174
|
+
try {
|
|
175
|
+
const sender = this.deps.senderId();
|
|
176
|
+
const creds = signed.bodyFields ?? {};
|
|
177
|
+
let url = `${this.deps.baseUrl}${path}`;
|
|
178
|
+
assertHubUrlSecure(url); // request-level scheme guard (defense in depth): even a misconfigured injected fetchFn cannot egress in plaintext
|
|
179
|
+
let body;
|
|
180
|
+
const headers = mergeRequestHeaders(requestHeaders, signed.headers);
|
|
181
|
+
if (method === 'GET') {
|
|
182
|
+
const qs = new URLSearchParams();
|
|
183
|
+
if (sender)
|
|
184
|
+
qs.set('sender_id', sender); // identifier, not a credential — query is fine
|
|
185
|
+
if (query)
|
|
186
|
+
for (const [k, v] of Object.entries(query))
|
|
187
|
+
if (v !== undefined)
|
|
188
|
+
qs.set(k, String(v)); // non-credential GET params (e.g. semantic-search q)
|
|
189
|
+
// #8: credentials must NOT go in the query (leaks to access logs / proxies even over https).
|
|
190
|
+
// node_secret travels via Authorization: Bearer; the hub reads it there, never from the query.
|
|
191
|
+
const nodeSecret = legacyNodeSecret(creds['node_secret']);
|
|
192
|
+
if (nodeSecret !== undefined)
|
|
193
|
+
setLegacyBearerFallback(headers, nodeSecret);
|
|
194
|
+
const q = qs.toString();
|
|
195
|
+
if (q)
|
|
196
|
+
url += `?${q}`;
|
|
102
197
|
}
|
|
103
|
-
|
|
198
|
+
else {
|
|
199
|
+
const postCreds = { ...creds };
|
|
200
|
+
const postBody = { ...(bodyObj ?? {}) };
|
|
201
|
+
const nodeSecret = legacyNodeSecret(postCreds['node_secret']);
|
|
202
|
+
if (usesLegacyBearerForPost(method, path, bodyObj)) {
|
|
203
|
+
delete postBody['node_secret'];
|
|
204
|
+
if (nodeSecret !== undefined && setLegacyBearerFallback(headers, nodeSecret)) {
|
|
205
|
+
delete postCreds['node_secret'];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (path === '/a2a/mailbox/outbound' && sender) {
|
|
209
|
+
const qs = new URLSearchParams({ sender_id: sender });
|
|
210
|
+
url += `?${qs.toString()}`;
|
|
211
|
+
}
|
|
212
|
+
body = JSON.stringify({ ...(sender ? { sender_id: sender } : {}), ...postCreds, ...postBody });
|
|
213
|
+
}
|
|
214
|
+
let res;
|
|
215
|
+
try {
|
|
216
|
+
res = await awaitWithAbort(this.deps.fetchFn(url, {
|
|
217
|
+
method,
|
|
218
|
+
headers,
|
|
219
|
+
...(body ? { body } : {}),
|
|
220
|
+
signal: deadline.signal,
|
|
221
|
+
redirect: 'manual',
|
|
222
|
+
}), deadline.signal);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (deadline.signal.aborted)
|
|
226
|
+
throw deadline.error;
|
|
227
|
+
if (isHubUnreachableError(err)) {
|
|
228
|
+
throw new HubUnreachableError(`${method} ${path} failed before a Hub API response arrived`, { context: `${method} ${path}`, retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS });
|
|
229
|
+
}
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
if (deadline.signal.aborted)
|
|
233
|
+
throw deadline.error;
|
|
234
|
+
const retryAfterMs = parseRetryAfterMs(headerValue(res.headers, 'retry-after'), this.deps.now?.() ?? Date.now());
|
|
235
|
+
if (res.status >= 300 && res.status < 400) {
|
|
236
|
+
await drainHubResponse(res, { signal: deadline.signal });
|
|
237
|
+
if (deadline.signal.aborted)
|
|
238
|
+
throw deadline.error;
|
|
239
|
+
throw new HubUnreachableError(`${method} ${path} refused an unexpected Hub redirect`, { status: res.status, context: `${method} ${path}`, retryAfterMs: retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS });
|
|
240
|
+
}
|
|
241
|
+
const parsed = await readHubResponseJsonForClassification(res, deadline.signal);
|
|
242
|
+
if (deadline.signal.aborted)
|
|
243
|
+
throw deadline.error;
|
|
244
|
+
throwIfParsedHubUnreachableResponse(res, parsed, `${method} ${path}`, retryAfterMs);
|
|
245
|
+
if (res.status === 401 || res.status === 403)
|
|
246
|
+
throw new AuthError(res.status, parsed.body);
|
|
247
|
+
if (res.status >= 400 && res.status < 500) {
|
|
248
|
+
throw new HubClientError(res.status, parsed.ok ? parsed.body : {}, retryAfterMs);
|
|
249
|
+
}
|
|
250
|
+
if (res.status >= 500)
|
|
251
|
+
throw new Error(`hub ${res.status}`);
|
|
252
|
+
return parsed.body;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
deadline.dispose();
|
|
104
256
|
}
|
|
105
|
-
const parsed = await readHubResponseJsonForClassification(res);
|
|
106
|
-
throwIfParsedHubUnreachableResponse(res, parsed, `${method} ${path}`);
|
|
107
|
-
if (res.status === 401 || res.status === 403)
|
|
108
|
-
throw new AuthError(res.status, parsed.body);
|
|
109
|
-
if (res.status >= 400 && res.status < 500)
|
|
110
|
-
throw new HubClientError(res.status, parsed.ok ? parsed.body : {});
|
|
111
|
-
if (res.status >= 500)
|
|
112
|
-
throw new Error(`hub ${res.status}`);
|
|
113
|
-
return parsed.body;
|
|
114
257
|
}
|
|
115
258
|
}
|
|
259
|
+
function positiveTimeoutMs(value, fallback) {
|
|
260
|
+
if (value === undefined || value === '')
|
|
261
|
+
return fallback;
|
|
262
|
+
const raw = String(value);
|
|
263
|
+
if (!/^\d+$/.test(raw))
|
|
264
|
+
return fallback;
|
|
265
|
+
const parsed = Number(raw);
|
|
266
|
+
return Number.isFinite(parsed) && parsed > 0 && parsed < 2 ** 31 ? parsed : fallback;
|
|
267
|
+
}
|
|
268
|
+
export function resolveHubOperationTimeouts(env = process.env, overrides = {}) {
|
|
269
|
+
const fromEnv = {
|
|
270
|
+
generalMs: positiveTimeoutMs(env['EVOLVER_HTTP_TRANSPORT_TIMEOUT_MS'], HUB_GENERAL_TIMEOUT_MS),
|
|
271
|
+
searchMs: positiveTimeoutMs(env['EVOLVER_HUB_SEARCH_TIMEOUT_MS'], HUB_SEARCH_TIMEOUT_MS),
|
|
272
|
+
heartbeatMs: positiveTimeoutMs(env['EVOLVER_HEARTBEAT_TIMEOUT_MS'], HUB_HEARTBEAT_TIMEOUT_MS),
|
|
273
|
+
pollMs: positiveTimeoutMs(env['EVOLVER_EVENT_POLL_TIMEOUT_MS'], HUB_EVENT_POLL_TIMEOUT_MS),
|
|
274
|
+
helloMs: positiveTimeoutMs(env['EVOLVER_HELLO_TIMEOUT_MS'], HUB_HELLO_TIMEOUT_MS),
|
|
275
|
+
};
|
|
276
|
+
return {
|
|
277
|
+
generalMs: positiveTimeoutMs(overrides.generalMs, fromEnv.generalMs),
|
|
278
|
+
searchMs: positiveTimeoutMs(overrides.searchMs, fromEnv.searchMs),
|
|
279
|
+
heartbeatMs: positiveTimeoutMs(overrides.heartbeatMs, fromEnv.heartbeatMs),
|
|
280
|
+
pollMs: positiveTimeoutMs(overrides.pollMs, fromEnv.pollMs),
|
|
281
|
+
helloMs: positiveTimeoutMs(overrides.helloMs, fromEnv.helloMs),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function hubOperationForRequest(path, bodyObj) {
|
|
285
|
+
if (path === '/a2a/fetch') {
|
|
286
|
+
const payload = bodyObj?.['payload'];
|
|
287
|
+
if (payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
|
288
|
+
&& payload['search_only'] === true)
|
|
289
|
+
return 'search';
|
|
290
|
+
}
|
|
291
|
+
if (path === '/a2a/assets/semantic-search' || path === '/a2a/directory/search' || path === '/a2a/recipe/search' || path === '/a2a/recipe/list')
|
|
292
|
+
return 'search';
|
|
293
|
+
if (path === '/a2a/heartbeat')
|
|
294
|
+
return 'heartbeat';
|
|
295
|
+
if (path === '/a2a/events/poll')
|
|
296
|
+
return 'poll';
|
|
297
|
+
if (path === '/a2a/hello')
|
|
298
|
+
return 'hello';
|
|
299
|
+
return 'general';
|
|
300
|
+
}
|
|
301
|
+
function createHubDeadline(scheduler, method, path, operation, timeoutMs) {
|
|
302
|
+
const controller = new AbortController();
|
|
303
|
+
const error = new HubUnreachableError(`${method} ${path} timed out after ${timeoutMs}ms`, {
|
|
304
|
+
context: `${method} ${path}`,
|
|
305
|
+
retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
306
|
+
operation,
|
|
307
|
+
timeoutMs,
|
|
308
|
+
});
|
|
309
|
+
const handle = scheduler.set(() => controller.abort(error), timeoutMs);
|
|
310
|
+
return {
|
|
311
|
+
signal: controller.signal,
|
|
312
|
+
error,
|
|
313
|
+
dispose: () => scheduler.clear(handle),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function abortReason(signal) {
|
|
317
|
+
if (signal.reason instanceof Error)
|
|
318
|
+
return signal.reason;
|
|
319
|
+
const error = new Error('Hub request aborted');
|
|
320
|
+
error.name = 'AbortError';
|
|
321
|
+
return error;
|
|
322
|
+
}
|
|
323
|
+
async function awaitWithAbort(promise, signal) {
|
|
324
|
+
const pending = Promise.resolve(promise);
|
|
325
|
+
if (!signal)
|
|
326
|
+
return await pending;
|
|
327
|
+
if (signal.aborted) {
|
|
328
|
+
void pending.catch(() => { });
|
|
329
|
+
throw abortReason(signal);
|
|
330
|
+
}
|
|
331
|
+
return await new Promise((resolve, reject) => {
|
|
332
|
+
const onAbort = () => {
|
|
333
|
+
cleanup();
|
|
334
|
+
reject(abortReason(signal));
|
|
335
|
+
};
|
|
336
|
+
const cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
337
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
338
|
+
void pending.then((value) => {
|
|
339
|
+
cleanup();
|
|
340
|
+
resolve(value);
|
|
341
|
+
}, (error) => {
|
|
342
|
+
cleanup();
|
|
343
|
+
reject(error);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
116
347
|
function hubErrorCode(body) {
|
|
117
348
|
const record = body && typeof body === 'object' && !Array.isArray(body) ? body : undefined;
|
|
118
349
|
if (!record)
|
|
@@ -149,6 +380,19 @@ function headerValue(headers, name) {
|
|
|
149
380
|
return '';
|
|
150
381
|
}
|
|
151
382
|
}
|
|
383
|
+
function parseRetryAfterMs(value, now) {
|
|
384
|
+
const raw = value.trim();
|
|
385
|
+
if (!raw || !Number.isFinite(now))
|
|
386
|
+
return undefined;
|
|
387
|
+
if (/^\d+$/.test(raw)) {
|
|
388
|
+
const milliseconds = Number(raw) * 1_000;
|
|
389
|
+
return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
|
|
390
|
+
}
|
|
391
|
+
if (/^[+-]?\d+(?:\.\d+)?$/.test(raw))
|
|
392
|
+
return undefined;
|
|
393
|
+
const retryAt = Date.parse(raw);
|
|
394
|
+
return Number.isFinite(retryAt) ? Math.max(0, retryAt - now) : undefined;
|
|
395
|
+
}
|
|
152
396
|
export function hubResponseContentType(res) {
|
|
153
397
|
return headerValue(res?.headers, 'content-type').toLowerCase();
|
|
154
398
|
}
|
|
@@ -176,6 +420,10 @@ const NETWORK_DISRUPTION_CODES = new Set([
|
|
|
176
420
|
'UND_ERR_HEADERS_TIMEOUT',
|
|
177
421
|
'UND_ERR_BODY_TIMEOUT',
|
|
178
422
|
]);
|
|
423
|
+
function isAuthTransportTimeout(error) {
|
|
424
|
+
return error instanceof Error
|
|
425
|
+
&& (error.message === 'oauth_refresh_timeout' || error.message === 'device_flow_timeout');
|
|
426
|
+
}
|
|
179
427
|
export function isHubUnreachableError(err) {
|
|
180
428
|
const e = err;
|
|
181
429
|
if (!e)
|
|
@@ -207,11 +455,11 @@ function toBytes(value) {
|
|
|
207
455
|
return Buffer.from(value, 'utf8');
|
|
208
456
|
return Buffer.from(String(value ?? ''), 'utf8');
|
|
209
457
|
}
|
|
210
|
-
export async function drainHubResponse(res) {
|
|
458
|
+
export async function drainHubResponse(res, opts = {}) {
|
|
211
459
|
const body = res?.body;
|
|
212
460
|
try {
|
|
213
461
|
if (body && typeof body.cancel === 'function') {
|
|
214
|
-
await body.cancel();
|
|
462
|
+
await awaitWithAbort(Promise.resolve(body.cancel()), opts.signal);
|
|
215
463
|
}
|
|
216
464
|
}
|
|
217
465
|
catch {
|
|
@@ -228,7 +476,7 @@ export async function readHubResponseText(res, opts = {}) {
|
|
|
228
476
|
let truncated = false;
|
|
229
477
|
try {
|
|
230
478
|
for (;;) {
|
|
231
|
-
const part = await reader.read();
|
|
479
|
+
const part = await awaitWithAbort(reader.read(), opts.signal);
|
|
232
480
|
if (part.done)
|
|
233
481
|
break;
|
|
234
482
|
const bytes = toBytes(part.value);
|
|
@@ -239,7 +487,7 @@ export async function readHubResponseText(res, opts = {}) {
|
|
|
239
487
|
total += remaining;
|
|
240
488
|
}
|
|
241
489
|
truncated = true;
|
|
242
|
-
await reader.
|
|
490
|
+
await cancelReader(reader, opts.signal);
|
|
243
491
|
break;
|
|
244
492
|
}
|
|
245
493
|
chunks.push(bytes);
|
|
@@ -247,7 +495,7 @@ export async function readHubResponseText(res, opts = {}) {
|
|
|
247
495
|
}
|
|
248
496
|
}
|
|
249
497
|
catch (err) {
|
|
250
|
-
await reader.
|
|
498
|
+
await cancelReader(reader, opts.signal);
|
|
251
499
|
throw err;
|
|
252
500
|
}
|
|
253
501
|
finally {
|
|
@@ -260,14 +508,27 @@ export async function readHubResponseText(res, opts = {}) {
|
|
|
260
508
|
return '';
|
|
261
509
|
throw new Error('hub response body stream missing');
|
|
262
510
|
}
|
|
511
|
+
async function cancelReader(reader, signal) {
|
|
512
|
+
try {
|
|
513
|
+
if (reader.cancel)
|
|
514
|
+
await awaitWithAbort(Promise.resolve(reader.cancel()), signal);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// Cancellation is best-effort; preserve the read/timeout error.
|
|
518
|
+
}
|
|
519
|
+
}
|
|
263
520
|
export async function readHubResponseJson(res, opts = {}) {
|
|
264
|
-
const text = await readHubResponseText(res, {
|
|
521
|
+
const text = await readHubResponseText(res, {
|
|
522
|
+
maxBytes: opts.maxBytes ?? HUB_JSON_TEXT_MAX_BYTES,
|
|
523
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
524
|
+
});
|
|
265
525
|
return JSON.parse(text);
|
|
266
526
|
}
|
|
267
527
|
export async function throwIfHubUnreachableResponse(res, context = 'hub') {
|
|
528
|
+
const retryAfterMs = parseRetryAfterMs(headerValue(res.headers, 'retry-after'), Date.now());
|
|
268
529
|
if (!isHubUnreachableResponse(res)) {
|
|
269
530
|
const parsed = await readHubResponseJsonForClassification(res);
|
|
270
|
-
throwIfParsedHubUnreachableResponse(res, parsed, context);
|
|
531
|
+
throwIfParsedHubUnreachableResponse(res, parsed, context, retryAfterMs);
|
|
271
532
|
return;
|
|
272
533
|
}
|
|
273
534
|
const status = Number(res.status) || undefined;
|
|
@@ -278,22 +539,30 @@ export async function throwIfHubUnreachableResponse(res, context = 'hub') {
|
|
|
278
539
|
catch {
|
|
279
540
|
// Best-effort pool hygiene only.
|
|
280
541
|
}
|
|
281
|
-
throw new HubUnreachableError(`${context} returned a non-API Hub response (${status ?? 'unknown status'}, ${contentType})`, {
|
|
542
|
+
throw new HubUnreachableError(`${context} returned a non-API Hub response (${status ?? 'unknown status'}, ${contentType})`, {
|
|
543
|
+
...(status !== undefined ? { status } : {}),
|
|
544
|
+
contentType,
|
|
545
|
+
context,
|
|
546
|
+
retryAfterMs: retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
547
|
+
});
|
|
282
548
|
}
|
|
283
|
-
async function readHubResponseJsonForClassification(res) {
|
|
549
|
+
async function readHubResponseJsonForClassification(res, signal) {
|
|
284
550
|
if (isHubUnreachableResponse(res)) {
|
|
285
|
-
await drainHubResponse(res);
|
|
551
|
+
await drainHubResponse(res, { ...(signal ? { signal } : {}) });
|
|
286
552
|
return { ok: false, reason: 'non_api_content_type' };
|
|
287
553
|
}
|
|
288
554
|
try {
|
|
289
|
-
const text = await readHubResponseText(res, {
|
|
555
|
+
const text = await readHubResponseText(res, {
|
|
556
|
+
maxBytes: HUB_JSON_TEXT_MAX_BYTES,
|
|
557
|
+
...(signal ? { signal } : {}),
|
|
558
|
+
});
|
|
290
559
|
return { ok: true, body: JSON.parse(text) };
|
|
291
560
|
}
|
|
292
561
|
catch (err) {
|
|
293
562
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
294
563
|
}
|
|
295
564
|
}
|
|
296
|
-
function throwIfParsedHubUnreachableResponse(res, parsed, context) {
|
|
565
|
+
function throwIfParsedHubUnreachableResponse(res, parsed, context, retryAfterMs) {
|
|
297
566
|
const status = Number(res.status) || undefined;
|
|
298
567
|
const contentType = hubResponseContentType(res);
|
|
299
568
|
if (!isHubApiResponse(res) || !parsed.ok) {
|
|
@@ -301,7 +570,7 @@ function throwIfParsedHubUnreachableResponse(res, parsed, context) {
|
|
|
301
570
|
...(status !== undefined ? { status } : {}),
|
|
302
571
|
contentType: contentType || 'unknown content-type',
|
|
303
572
|
context,
|
|
304
|
-
retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
573
|
+
retryAfterMs: retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
305
574
|
});
|
|
306
575
|
}
|
|
307
576
|
}
|
|
@@ -337,6 +606,7 @@ export function assertHubUrlSecure(url, env = process.env) {
|
|
|
337
606
|
}
|
|
338
607
|
export const HUB_CONNECT_TIMEOUT_MS = 10_000;
|
|
339
608
|
export const HUB_IPV4FIRST_PRIMARY_CONNECT_TIMEOUT_MS = 2_500;
|
|
609
|
+
export const HUB_TCP_KEEPALIVE_IDLE_MS = 15_000;
|
|
340
610
|
export function resolveHubIpFamily(env = process.env) {
|
|
341
611
|
const raw = String(env['EVOMAP_HUB_IP_FAMILY'] ?? 'ipv4first').trim().toLowerCase();
|
|
342
612
|
if (raw === 'ipv4' || raw === 'v4' || raw === '4' || raw === 'ipv4first' || raw === 'ipv4-first')
|
|
@@ -401,24 +671,41 @@ function errorCode(err) {
|
|
|
401
671
|
function shouldFallbackFromIpv4(err, hubIpFamily) {
|
|
402
672
|
return hubIpFamily === 'ipv4first' && IPV4_FALLBACK_CODES.has(errorCode(err) ?? '');
|
|
403
673
|
}
|
|
674
|
+
function configureHubSocket(socket) {
|
|
675
|
+
if (socket === null || typeof socket !== 'object')
|
|
676
|
+
return;
|
|
677
|
+
const setKeepAlive = socket.setKeepAlive;
|
|
678
|
+
if (typeof setKeepAlive !== 'function')
|
|
679
|
+
return;
|
|
680
|
+
try {
|
|
681
|
+
setKeepAlive.call(socket, true, HUB_TCP_KEEPALIVE_IDLE_MS);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// Kernel/socket support varies. A keepalive tuning failure must never turn a valid TLS connection into an outage.
|
|
685
|
+
}
|
|
686
|
+
}
|
|
404
687
|
function makeHubConnector(config) {
|
|
405
688
|
const primaryConnect = buildConnector(config.primaryConnectOpts);
|
|
406
689
|
const fallbackConnect = config.fallbackConnectOpts ? buildConnector(config.fallbackConnectOpts) : null;
|
|
407
690
|
const connector = (opts, cb) => {
|
|
408
|
-
|
|
409
|
-
if (err && fallbackConnect && shouldFallbackFromIpv4(err, config.hubIpFamily)) {
|
|
410
|
-
fallbackConnect(opts, cb);
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
691
|
+
const finish = (err, socket) => {
|
|
413
692
|
if (err) {
|
|
414
693
|
cb(err, null);
|
|
415
694
|
return;
|
|
416
695
|
}
|
|
417
|
-
if (socket) {
|
|
418
|
-
cb(
|
|
696
|
+
if (!socket) {
|
|
697
|
+
cb(new Error('[hubFetch] undici connector returned no socket'), null);
|
|
419
698
|
return;
|
|
420
699
|
}
|
|
421
|
-
|
|
700
|
+
configureHubSocket(socket);
|
|
701
|
+
cb(null, socket);
|
|
702
|
+
};
|
|
703
|
+
primaryConnect(opts, (err, socket) => {
|
|
704
|
+
if (err && fallbackConnect && shouldFallbackFromIpv4(err, config.hubIpFamily)) {
|
|
705
|
+
fallbackConnect(opts, finish);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
finish(err, socket);
|
|
422
709
|
});
|
|
423
710
|
};
|
|
424
711
|
const marked = connector;
|
|
@@ -429,7 +716,12 @@ const HUB_FETCH_CONFIG = makeHubFetchTransportConfig(process.env);
|
|
|
429
716
|
// Singleton TLS-enforcing dispatcher: overrides a global NODE_TLS_REJECT_UNAUTHORIZED=0. The Agent and
|
|
430
717
|
// fetch MUST come from the same undici package (mixing an npm-undici Agent with Node's built-in global.fetch
|
|
431
718
|
// throws UND_ERR_INVALID_ARG). The connector applies TLS verification plus the selected Hub IP-family policy.
|
|
432
|
-
const STRICT_TLS_AGENT = new Agent({
|
|
719
|
+
const STRICT_TLS_AGENT = new Agent({
|
|
720
|
+
connect: makeHubConnector(HUB_FETCH_CONFIG),
|
|
721
|
+
keepAliveTimeout: 10_000,
|
|
722
|
+
keepAliveMaxTimeout: 60_000,
|
|
723
|
+
pipelining: 1,
|
|
724
|
+
});
|
|
433
725
|
export function _getHubFetchConfigForTest(env) {
|
|
434
726
|
return env ? makeHubFetchTransportConfig(env) : {
|
|
435
727
|
...HUB_FETCH_CONFIG,
|
|
@@ -441,6 +733,9 @@ export function _getHubFetchConfigForTest(env) {
|
|
|
441
733
|
export function _shouldFallbackFromIpv4ForTest(err, hubIpFamily = HUB_FETCH_CONFIG.hubIpFamily) {
|
|
442
734
|
return shouldFallbackFromIpv4(err, hubIpFamily);
|
|
443
735
|
}
|
|
736
|
+
export function _configureHubSocketForTest(socket) {
|
|
737
|
+
configureHubSocket(socket);
|
|
738
|
+
}
|
|
444
739
|
// Test seam: lets unit tests swap the underlying fetch without forking the call path; production must never reassign it from outside.
|
|
445
740
|
let _fetchImpl = undiciFetch;
|
|
446
741
|
export function _setFetchImplForTest(fn) { _fetchImpl = fn ?? undiciFetch; }
|
|
@@ -458,7 +753,7 @@ function warnInsecureOnce() {
|
|
|
458
753
|
export function _resetInsecureWarningForTest() { _insecureWarned = false; }
|
|
459
754
|
/** Default production transport: secure mode = https guard + forced TLS dispatcher; escape-hatch mode = skip both (local dev). */
|
|
460
755
|
export const globalFetchLike = async (url, init) => {
|
|
461
|
-
const raw = init;
|
|
756
|
+
const raw = { ...init, redirect: 'manual' };
|
|
462
757
|
if (insecureAllowed(process.env)) {
|
|
463
758
|
warnInsecureOnce();
|
|
464
759
|
return (await _fetchImpl(url, raw));
|