neoagent 3.2.1-beta.2 → 3.2.1-beta.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/extensions/chrome-browser/background.mjs +318 -88
- package/extensions/chrome-browser/http.mjs +136 -0
- package/extensions/chrome-browser/protocol.mjs +511 -89
- package/flutter_app/lib/main_chat.dart +118 -739
- package/flutter_app/lib/main_controller.dart +29 -20
- package/flutter_app/lib/main_models.dart +3 -0
- package/flutter_app/lib/main_operations.dart +334 -321
- package/flutter_app/lib/main_settings.dart +4 -3
- package/flutter_app/lib/main_shared.dart +14 -11
- package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
- package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
- package/flutter_app/windows/runner/flutter_window.cpp +143 -32
- package/lib/manager.js +106 -89
- package/lib/schema_migrations.js +67 -13
- package/package.json +20 -13
- package/runtime/paths.js +49 -5
- package/server/guest_agent.js +52 -30
- package/server/http/middleware.js +24 -0
- package/server/http/routes.js +4 -6
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +30803 -30805
- package/server/routes/admin.js +1 -1
- package/server/routes/android.js +30 -34
- package/server/routes/browser.js +23 -15
- package/server/routes/desktop.js +18 -1
- package/server/routes/integrations.js +5 -1
- package/server/routes/memory.js +1 -0
- package/server/routes/settings.js +16 -5
- package/server/routes/social_reach.js +12 -3
- package/server/routes/social_video.js +4 -0
- package/server/services/ai/compaction.js +7 -2
- package/server/services/ai/history.js +44 -5
- package/server/services/ai/integrated_tools/http_request.js +8 -0
- package/server/services/ai/loop/agent_engine_core.js +347 -162
- package/server/services/ai/loop/callbacks.js +1 -0
- package/server/services/ai/loop/completion_judge.js +12 -0
- package/server/services/ai/loop/conversation_loop.js +348 -242
- package/server/services/ai/loop/messaging_delivery.js +129 -57
- package/server/services/ai/loop/model_call_guard.js +91 -0
- package/server/services/ai/loop/model_io.js +8 -41
- package/server/services/ai/loop/tool_dispatch.js +19 -8
- package/server/services/ai/loopPolicy.js +24 -19
- package/server/services/ai/model_discovery.js +227 -0
- package/server/services/ai/model_identity.js +71 -0
- package/server/services/ai/models.js +67 -162
- package/server/services/ai/providerRetry.js +17 -59
- package/server/services/ai/provider_selector.js +111 -0
- package/server/services/ai/providers/anthropic.js +2 -2
- package/server/services/ai/providers/claudeCode.js +15 -28
- package/server/services/ai/providers/githubCopilot.js +36 -16
- package/server/services/ai/providers/google.js +23 -5
- package/server/services/ai/providers/grok.js +4 -3
- package/server/services/ai/providers/grokOauth.js +13 -22
- package/server/services/ai/providers/nvidia.js +10 -5
- package/server/services/ai/providers/ollama.js +102 -82
- package/server/services/ai/providers/ollama_stream.js +142 -0
- package/server/services/ai/providers/openai.js +39 -5
- package/server/services/ai/providers/openaiCodex.js +11 -4
- package/server/services/ai/providers/openrouter.js +29 -7
- package/server/services/ai/providers/provider_error.js +36 -0
- package/server/services/ai/settings.js +26 -2
- package/server/services/ai/taskAnalysis.js +5 -1
- package/server/services/ai/terminal_reply.js +45 -0
- package/server/services/ai/toolEvidence.js +58 -29
- package/server/services/ai/tools.js +124 -111
- package/server/services/android/controller.js +770 -237
- package/server/services/android/process.js +140 -0
- package/server/services/android/sdk_download.js +143 -0
- package/server/services/android/uia.js +6 -5
- package/server/services/artifacts/store.js +24 -0
- package/server/services/browser/controller.js +736 -385
- package/server/services/browser/extension/gateway.js +40 -16
- package/server/services/browser/extension/protocol.js +12 -1
- package/server/services/browser/extension/provider.js +59 -47
- package/server/services/browser/extension/registry.js +155 -34
- package/server/services/cli/executor.js +62 -9
- package/server/services/desktop/gateway.js +41 -4
- package/server/services/desktop/protocol.js +3 -0
- package/server/services/desktop/provider.js +39 -42
- package/server/services/desktop/registry.js +137 -52
- package/server/services/integrations/figma/provider.js +78 -12
- package/server/services/integrations/github/common.js +11 -6
- package/server/services/integrations/github/provider.js +52 -53
- package/server/services/integrations/google/provider.js +55 -19
- package/server/services/integrations/home_assistant/network.js +17 -20
- package/server/services/integrations/home_assistant/provider.js +7 -5
- package/server/services/integrations/home_assistant/tools.js +17 -5
- package/server/services/integrations/http.js +51 -0
- package/server/services/integrations/manager.js +158 -53
- package/server/services/integrations/microsoft/provider.js +80 -13
- package/server/services/integrations/neoarchive/provider.js +55 -29
- package/server/services/integrations/neorecall/client.js +17 -10
- package/server/services/integrations/neorecall/provider.js +20 -11
- package/server/services/integrations/notion/provider.js +16 -13
- package/server/services/integrations/oauth_provider.js +115 -51
- package/server/services/integrations/slack/provider.js +98 -9
- package/server/services/integrations/spotify/provider.js +67 -71
- package/server/services/integrations/trello/provider.js +21 -7
- package/server/services/integrations/weather/provider.js +18 -12
- package/server/services/integrations/whatsapp/provider.js +76 -16
- package/server/services/manager.js +87 -1
- package/server/services/memory/embedding_index.js +20 -8
- package/server/services/memory/embeddings.js +151 -90
- package/server/services/memory/ingestion.js +50 -9
- package/server/services/memory/ingestion_documents.js +13 -3
- package/server/services/memory/manager.js +52 -19
- package/server/services/messaging/automation.js +84 -9
- package/server/services/messaging/http_platforms.js +33 -13
- package/server/services/messaging/inbound_queue.js +78 -24
- package/server/services/messaging/inbound_store.js +224 -0
- package/server/services/messaging/manager.js +326 -51
- package/server/services/messaging/typing_keepalive.js +5 -2
- package/server/services/network/http.js +210 -0
- package/server/services/network/safe_request.js +307 -0
- package/server/services/runtime/backends/local-vm.js +214 -66
- package/server/services/runtime/manager.js +17 -12
- package/server/services/social_reach/channels/github.js +10 -4
- package/server/services/social_reach/channels/reddit.js +4 -4
- package/server/services/social_reach/channels/rss.js +2 -2
- package/server/services/social_reach/channels/social_video.js +12 -7
- package/server/services/social_reach/channels/v2ex.js +21 -8
- package/server/services/social_reach/channels/x.js +2 -2
- package/server/services/social_reach/channels/xueqiu.js +5 -5
- package/server/services/social_reach/service.js +9 -6
- package/server/services/social_reach/utils.js +65 -14
- package/server/services/social_video/service.js +160 -50
- package/server/services/tasks/integration_runtime.js +18 -8
- package/server/services/tasks/runtime.js +39 -4
- package/server/services/voice/agentBridge.js +17 -4
- package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
- package/server/services/voice/liveSession.js +31 -0
- package/server/services/voice/openaiSpeech.js +33 -8
- package/server/services/voice/providers.js +233 -151
- package/server/services/voice/runtimeManager.js +118 -20
- package/server/services/voice/turnRunner.js +6 -0
- package/server/services/wearable/firmware_manifest.js +51 -13
- package/server/services/wearable/service.js +1 -0
- package/server/utils/abort.js +96 -0
- package/server/utils/cloud-security.js +110 -3
- package/server/utils/files.js +31 -0
- package/server/utils/image_payload.js +95 -0
- package/server/utils/retry.js +107 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const dns = require('node:dns');
|
|
4
|
+
const net = require('node:net');
|
|
5
|
+
|
|
6
|
+
const ALLOWED_SCHEMES = new Set(['http', 'https']);
|
|
7
|
+
const BLOCKED_ANDROID_INTENT_SCHEMES = new Set([
|
|
8
|
+
'about',
|
|
9
|
+
'chrome',
|
|
10
|
+
'chrome-extension',
|
|
11
|
+
'content',
|
|
12
|
+
'data',
|
|
13
|
+
'file',
|
|
14
|
+
'javascript',
|
|
15
|
+
'vbscript',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function urlAbortError(signal) {
|
|
19
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
20
|
+
const error = new Error('URL validation was aborted.');
|
|
21
|
+
error.name = 'AbortError';
|
|
22
|
+
error.code = 'ABORT_ERR';
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
25
|
+
|
|
3
26
|
// URL schemes that must never be navigated to in the cloud browser or Android.
|
|
4
27
|
const BLOCKED_SCHEMES = new Set([
|
|
5
28
|
'javascript',
|
|
@@ -56,8 +79,26 @@ function isPrivateHost(hostname) {
|
|
|
56
79
|
|
|
57
80
|
// IPv6 loopback, link-local and unique-local
|
|
58
81
|
if (h === '::1' || h === '::') return true;
|
|
59
|
-
if (h
|
|
82
|
+
if (/^fe[89ab][0-9a-f]:/.test(h)) return true; // fe80::/10 link-local
|
|
83
|
+
if (/^fe[c-f][0-9a-f]:/.test(h)) return true; // fec0::/10 deprecated site-local
|
|
60
84
|
if (/^f[cd][0-9a-f]*:/.test(h)) return true; // fc00::/7 unique-local
|
|
85
|
+
if (h.startsWith('ff')) return true; // IPv6 multicast
|
|
86
|
+
if (h === '100::' || h.startsWith('100::')) return true; // discard-only prefix
|
|
87
|
+
if (h === '2001:db8::' || h.startsWith('2001:db8:')) return true; // documentation
|
|
88
|
+
|
|
89
|
+
if (net.isIPv4(h)) {
|
|
90
|
+
const [a, b, c] = h.split('.').map(Number);
|
|
91
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) return true;
|
|
92
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
93
|
+
if (a === 169 && b === 254) return true;
|
|
94
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
95
|
+
if (a === 192 && b === 168) return true;
|
|
96
|
+
if (a === 192 && b === 0 && c === 0) return true;
|
|
97
|
+
if (a === 192 && b === 0 && c === 2) return true;
|
|
98
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
99
|
+
if (a === 198 && b === 51 && c === 100) return true;
|
|
100
|
+
if (a === 203 && b === 0 && c === 113) return true;
|
|
101
|
+
}
|
|
61
102
|
|
|
62
103
|
for (const pattern of PRIVATE_IPV4) {
|
|
63
104
|
if (pattern.test(h)) return true;
|
|
@@ -90,7 +131,7 @@ function validateCloudUrl(urlString) {
|
|
|
90
131
|
}
|
|
91
132
|
|
|
92
133
|
const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
|
|
93
|
-
if (BLOCKED_SCHEMES.has(scheme)) return { allowed: false };
|
|
134
|
+
if (BLOCKED_SCHEMES.has(scheme) || !ALLOWED_SCHEMES.has(scheme)) return { allowed: false };
|
|
94
135
|
|
|
95
136
|
const hostname = parsed.hostname;
|
|
96
137
|
if (isPrivateHost(hostname)) return { allowed: false };
|
|
@@ -99,4 +140,70 @@ function validateCloudUrl(urlString) {
|
|
|
99
140
|
return { allowed: true };
|
|
100
141
|
}
|
|
101
142
|
|
|
102
|
-
|
|
143
|
+
async function validateCloudUrlWithDns(urlString, options = {}) {
|
|
144
|
+
if (options.signal?.aborted) {
|
|
145
|
+
throw urlAbortError(options.signal);
|
|
146
|
+
}
|
|
147
|
+
const syntactic = validateCloudUrl(urlString);
|
|
148
|
+
if (!syntactic.allowed) return syntactic;
|
|
149
|
+
|
|
150
|
+
const hostname = new URL(urlString).hostname.replace(/^\[|\]$/g, '');
|
|
151
|
+
if (net.isIP(hostname)) return { allowed: !isPrivateHost(hostname) };
|
|
152
|
+
|
|
153
|
+
const lookup = options.lookup || dns.promises.lookup;
|
|
154
|
+
const timeoutMs = Math.max(100, Math.min(Number(options.timeoutMs) || 5000, 30_000));
|
|
155
|
+
let timer = null;
|
|
156
|
+
let onAbort = null;
|
|
157
|
+
try {
|
|
158
|
+
const pending = [
|
|
159
|
+
lookup(hostname, { all: true, verbatim: true }),
|
|
160
|
+
new Promise((_, reject) => {
|
|
161
|
+
timer = setTimeout(() => reject(new Error('DNS lookup timed out.')), timeoutMs);
|
|
162
|
+
}),
|
|
163
|
+
];
|
|
164
|
+
if (options.signal) {
|
|
165
|
+
pending.push(new Promise((_, reject) => {
|
|
166
|
+
onAbort = () => {
|
|
167
|
+
reject(urlAbortError(options.signal));
|
|
168
|
+
};
|
|
169
|
+
options.signal.addEventListener('abort', onAbort, { once: true });
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
const addresses = await Promise.race(pending);
|
|
173
|
+
const resolved = Array.isArray(addresses) ? addresses : [addresses];
|
|
174
|
+
if (resolved.length === 0) return { allowed: false };
|
|
175
|
+
if (resolved.some((entry) => isPrivateHost(entry?.address || entry))) {
|
|
176
|
+
return { allowed: false };
|
|
177
|
+
}
|
|
178
|
+
return { allowed: true };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (options.signal?.aborted) throw error;
|
|
181
|
+
return { allowed: false };
|
|
182
|
+
} finally {
|
|
183
|
+
if (timer) clearTimeout(timer);
|
|
184
|
+
if (onAbort) options.signal?.removeEventListener('abort', onAbort);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function validateAndroidIntentUrl(urlString, options = {}) {
|
|
189
|
+
if (!urlString || typeof urlString !== 'string') return { allowed: false };
|
|
190
|
+
let parsed;
|
|
191
|
+
try {
|
|
192
|
+
parsed = new URL(urlString);
|
|
193
|
+
} catch {
|
|
194
|
+
return { allowed: false };
|
|
195
|
+
}
|
|
196
|
+
const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
|
|
197
|
+
if (BLOCKED_ANDROID_INTENT_SCHEMES.has(scheme)) return { allowed: false };
|
|
198
|
+
if (ALLOWED_SCHEMES.has(scheme)) {
|
|
199
|
+
return validateCloudUrlWithDns(urlString, options);
|
|
200
|
+
}
|
|
201
|
+
return { allowed: /^[a-z][a-z0-9+.-]*$/.test(scheme) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
validateAndroidIntentUrl,
|
|
206
|
+
validateCloudUrl,
|
|
207
|
+
validateCloudUrlWithDns,
|
|
208
|
+
isPrivateHost,
|
|
209
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { randomUUID } = require('node:crypto');
|
|
6
|
+
const { createAbortError, throwIfAborted } = require('./abort');
|
|
7
|
+
|
|
8
|
+
async function writeBufferAtomic(destination, data, options = {}) {
|
|
9
|
+
const target = String(destination || '').trim();
|
|
10
|
+
if (!target) throw new Error('Atomic file destination is required.');
|
|
11
|
+
const resolved = path.resolve(target);
|
|
12
|
+
const temporary = `${resolved}.${process.pid}.${randomUUID()}.tmp`;
|
|
13
|
+
throwIfAborted(options.signal, 'File write was aborted.');
|
|
14
|
+
try {
|
|
15
|
+
await fs.promises.writeFile(temporary, data, {
|
|
16
|
+
flag: 'wx',
|
|
17
|
+
mode: options.mode ?? 0o600,
|
|
18
|
+
signal: options.signal,
|
|
19
|
+
});
|
|
20
|
+
throwIfAborted(options.signal, 'File write was aborted.');
|
|
21
|
+
await fs.promises.rename(temporary, resolved);
|
|
22
|
+
return resolved;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (options.signal?.aborted) throw createAbortError(options.signal, 'File write was aborted.');
|
|
25
|
+
throw error;
|
|
26
|
+
} finally {
|
|
27
|
+
await fs.promises.rm(temporary, { force: true }).catch(() => {});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { writeBufferAtomic };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024;
|
|
4
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
5
|
+
|
|
6
|
+
function imageError(message, code = 'INVALID_IMAGE_PAYLOAD') {
|
|
7
|
+
const error = new Error(message);
|
|
8
|
+
error.code = code;
|
|
9
|
+
return error;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function detectImage(buffer) {
|
|
13
|
+
if (
|
|
14
|
+
buffer.length >= 24
|
|
15
|
+
&& buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
|
|
16
|
+
&& buffer.subarray(12, 16).toString('ascii') === 'IHDR'
|
|
17
|
+
) {
|
|
18
|
+
const width = buffer.readUInt32BE(16);
|
|
19
|
+
const height = buffer.readUInt32BE(20);
|
|
20
|
+
if (!width || !height || width > 32768 || height > 32768 || width * height > 150_000_000) {
|
|
21
|
+
throw imageError('Screenshot PNG has invalid or unsafe dimensions.');
|
|
22
|
+
}
|
|
23
|
+
return { contentType: 'image/png', extension: 'png', width, height };
|
|
24
|
+
}
|
|
25
|
+
if (
|
|
26
|
+
buffer.length >= 4
|
|
27
|
+
&& buffer[0] === 0xff
|
|
28
|
+
&& buffer[1] === 0xd8
|
|
29
|
+
&& buffer[buffer.length - 2] === 0xff
|
|
30
|
+
&& buffer[buffer.length - 1] === 0xd9
|
|
31
|
+
) {
|
|
32
|
+
return { contentType: 'image/jpeg', extension: 'jpg', width: null, height: null };
|
|
33
|
+
}
|
|
34
|
+
throw imageError('Screenshot payload is not a valid PNG or JPEG image.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validateImageBuffer(value, options = {}) {
|
|
38
|
+
const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value || []);
|
|
39
|
+
const maxBytes = Math.max(1024, Number(options.maxBytes) || MAX_SCREENSHOT_BYTES);
|
|
40
|
+
if (!buffer.length) throw imageError('Screenshot payload is empty.');
|
|
41
|
+
if (buffer.length > maxBytes) {
|
|
42
|
+
throw imageError(
|
|
43
|
+
`Screenshot payload exceeds the ${maxBytes}-byte limit.`,
|
|
44
|
+
'IMAGE_PAYLOAD_TOO_LARGE',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const detected = detectImage(buffer);
|
|
48
|
+
const allowedTypes = Array.isArray(options.allowedTypes) && options.allowedTypes.length
|
|
49
|
+
? new Set(options.allowedTypes.map((type) => String(type).toLowerCase()))
|
|
50
|
+
: null;
|
|
51
|
+
if (allowedTypes && !allowedTypes.has(detected.contentType)) {
|
|
52
|
+
throw imageError(`Screenshot type ${detected.contentType} is not allowed.`);
|
|
53
|
+
}
|
|
54
|
+
return { buffer, ...detected };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function decodeBase64Image(value, options = {}) {
|
|
58
|
+
const text = String(value || '');
|
|
59
|
+
if (!text) throw imageError('Screenshot payload is empty.');
|
|
60
|
+
let declaredType = null;
|
|
61
|
+
let encoded = text;
|
|
62
|
+
if (text.startsWith('data:')) {
|
|
63
|
+
const match = text.match(/^data:image\/(png|jpeg|jpg);base64,([A-Za-z0-9+/]*={0,2})$/i);
|
|
64
|
+
if (!match) throw imageError('Screenshot data URL is malformed or uses an unsupported type.');
|
|
65
|
+
declaredType = match[1].toLowerCase() === 'png' ? 'image/png' : 'image/jpeg';
|
|
66
|
+
encoded = match[2];
|
|
67
|
+
}
|
|
68
|
+
if (!encoded || encoded.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
|
|
69
|
+
throw imageError('Screenshot base64 payload is malformed.');
|
|
70
|
+
}
|
|
71
|
+
const maxBytes = Math.max(1024, Number(options.maxBytes) || MAX_SCREENSHOT_BYTES);
|
|
72
|
+
const padding = encoded.endsWith('==') ? 2 : (encoded.endsWith('=') ? 1 : 0);
|
|
73
|
+
const estimatedBytes = Math.floor(encoded.length * 3 / 4) - padding;
|
|
74
|
+
if (estimatedBytes > maxBytes) {
|
|
75
|
+
throw imageError(
|
|
76
|
+
`Screenshot payload exceeds the ${maxBytes}-byte limit.`,
|
|
77
|
+
'IMAGE_PAYLOAD_TOO_LARGE',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const buffer = Buffer.from(encoded, 'base64');
|
|
81
|
+
if (buffer.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) {
|
|
82
|
+
throw imageError('Screenshot base64 payload is malformed.');
|
|
83
|
+
}
|
|
84
|
+
const validated = validateImageBuffer(buffer, { ...options, maxBytes });
|
|
85
|
+
if (declaredType && declaredType !== validated.contentType) {
|
|
86
|
+
throw imageError('Screenshot data URL type does not match its image bytes.');
|
|
87
|
+
}
|
|
88
|
+
return validated;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
MAX_SCREENSHOT_BYTES,
|
|
93
|
+
decodeBase64Image,
|
|
94
|
+
validateImageBuffer,
|
|
95
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createAbortError, isAbortError } = require('./abort');
|
|
4
|
+
|
|
5
|
+
const RETRYABLE_HTTP_STATUS = new Set([
|
|
6
|
+
408, 409, 425, 429, 500, 502, 503, 504, 520, 521, 522, 524, 529,
|
|
7
|
+
]);
|
|
8
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
9
|
+
'ECONNRESET',
|
|
10
|
+
'ECONNREFUSED',
|
|
11
|
+
'ETIMEDOUT',
|
|
12
|
+
'EPIPE',
|
|
13
|
+
'EAI_AGAIN',
|
|
14
|
+
'ENOTFOUND',
|
|
15
|
+
'ENETUNREACH',
|
|
16
|
+
'EHOSTUNREACH',
|
|
17
|
+
'EAGAIN',
|
|
18
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
19
|
+
'UND_ERR_SOCKET',
|
|
20
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
21
|
+
'INTEGRATION_HTTP_TIMEOUT',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function getHttpStatus(error) {
|
|
25
|
+
if (!error || typeof error !== 'object') return null;
|
|
26
|
+
const candidates = [
|
|
27
|
+
error.status,
|
|
28
|
+
error.statusCode,
|
|
29
|
+
error.response?.status,
|
|
30
|
+
error.cause?.status,
|
|
31
|
+
];
|
|
32
|
+
for (const value of candidates) {
|
|
33
|
+
const status = Number(value);
|
|
34
|
+
if (Number.isFinite(status) && status >= 100 && status < 600) return status;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getErrorCode(error) {
|
|
40
|
+
if (!error || typeof error !== 'object') return null;
|
|
41
|
+
return error.code || error.errno || error.cause?.code || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readHeader(headers, name) {
|
|
45
|
+
if (!headers) return undefined;
|
|
46
|
+
if (typeof headers.get === 'function') return headers.get(name);
|
|
47
|
+
return headers[name] ?? headers[name.toLowerCase()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function retryAfterMilliseconds(headers, now = Date.now()) {
|
|
51
|
+
const milliseconds = readHeader(headers, 'retry-after-ms');
|
|
52
|
+
if (milliseconds !== undefined && milliseconds !== null && String(milliseconds).trim()) {
|
|
53
|
+
const parsed = Number(milliseconds);
|
|
54
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const retryAfter = readHeader(headers, 'retry-after');
|
|
58
|
+
if (retryAfter === undefined || retryAfter === null || !String(retryAfter).trim()) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const seconds = Number(retryAfter);
|
|
62
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
63
|
+
const date = Date.parse(String(retryAfter));
|
|
64
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function computeBackoffMs(attempt, baseDelayMs, maxDelayMs) {
|
|
68
|
+
const exp = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
|
|
69
|
+
return Math.round(exp / 2 + Math.random() * (exp / 2));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isTransientIoError(error) {
|
|
73
|
+
if (!error || isAbortError(error)) return false;
|
|
74
|
+
const status = getHttpStatus(error);
|
|
75
|
+
if (status !== null) return RETRYABLE_HTTP_STATUS.has(status);
|
|
76
|
+
const code = getErrorCode(error);
|
|
77
|
+
return Boolean(code && RETRYABLE_NETWORK_CODES.has(String(code)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function abortableDelay(milliseconds, signal = null) {
|
|
81
|
+
if (signal?.aborted) return Promise.reject(createAbortError(signal));
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
let timer = null;
|
|
84
|
+
const onAbort = () => {
|
|
85
|
+
if (timer) clearTimeout(timer);
|
|
86
|
+
signal?.removeEventListener('abort', onAbort);
|
|
87
|
+
reject(createAbortError(signal));
|
|
88
|
+
};
|
|
89
|
+
timer = setTimeout(() => {
|
|
90
|
+
signal?.removeEventListener('abort', onAbort);
|
|
91
|
+
resolve();
|
|
92
|
+
}, Math.max(0, Number(milliseconds) || 0));
|
|
93
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
RETRYABLE_HTTP_STATUS,
|
|
99
|
+
RETRYABLE_NETWORK_CODES,
|
|
100
|
+
abortableDelay,
|
|
101
|
+
computeBackoffMs,
|
|
102
|
+
getErrorCode,
|
|
103
|
+
getHttpStatus,
|
|
104
|
+
isTransientIoError,
|
|
105
|
+
readHeader,
|
|
106
|
+
retryAfterMilliseconds,
|
|
107
|
+
};
|