@apifuse/provider-sdk 2.2.0-beta.7 → 2.2.0-beta.9
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/CHANGELOG.md +8 -0
- package/dist/auth-turn/index.d.ts +2 -2
- package/dist/config/loader.d.ts +147 -6
- package/dist/config/loader.js +360 -48
- package/dist/define.js +63 -8
- package/dist/runtime/choice.js +20 -2
- package/dist/runtime/http.js +115 -9
- package/dist/runtime/proxy-errors.js +6 -2
- package/dist/runtime/proxy-nodemaven.d.ts +35 -0
- package/dist/runtime/proxy-nodemaven.js +128 -0
- package/dist/runtime/proxy-telemetry.d.ts +2 -1
- package/dist/runtime/proxy-telemetry.js +39 -4
- package/dist/runtime/state.js +12 -1
- package/dist/runtime/stealth.js +60 -18
- package/dist/server/serve.js +11 -0
- package/dist/server/types.d.ts +9 -9
- package/dist/types.d.ts +30 -1
- package/package.json +4 -3
- package/src/config/loader.ts +514 -61
- package/src/define.ts +76 -13
- package/src/runtime/choice.ts +25 -4
- package/src/runtime/http.ts +134 -11
- package/src/runtime/proxy-errors.ts +12 -4
- package/src/runtime/proxy-nodemaven.ts +178 -0
- package/src/runtime/proxy-telemetry.ts +56 -5
- package/src/runtime/state.ts +11 -1
- package/src/runtime/stealth.ts +68 -22
- package/src/server/serve.ts +11 -0
- package/src/types.ts +30 -1
package/dist/define.js
CHANGED
|
@@ -8,7 +8,7 @@ const VALID_RUNTIMES = ["standard", "shared", "browser"];
|
|
|
8
8
|
const VALID_AUTH_MODES = ["none", "platform-managed", "credentials", "oauth2"];
|
|
9
9
|
const VALID_PROVIDER_ACCESS_VISIBILITIES = ["public", "early_access"];
|
|
10
10
|
const VALID_PROVIDER_PROXY_MODES = ["disabled", "optional", "required"];
|
|
11
|
-
const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "decodo", "custom"];
|
|
11
|
+
const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "nodemaven", "decodo", "custom"];
|
|
12
12
|
const VALID_PROVIDER_PROXY_AFFINITIES = [
|
|
13
13
|
"request",
|
|
14
14
|
"operation",
|
|
@@ -17,6 +17,20 @@ const VALID_PROVIDER_PROXY_AFFINITIES = [
|
|
|
17
17
|
];
|
|
18
18
|
const VALID_PROVIDER_STT_MODES = ["optional", "required"];
|
|
19
19
|
const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
|
|
20
|
+
const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
|
|
21
|
+
const NODEMAVEN_PASSWORD_SECRET = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
|
|
22
|
+
// Per-vendor provider-declared credential secrets. A required-mode chain must
|
|
23
|
+
// declare every secret of every credentialed vendor it names, so a missing
|
|
24
|
+
// credential fails at build/validation time rather than during a live outage: a
|
|
25
|
+
// declared-but-uncredentialed fallback leg is a silently dead SPOF, which is
|
|
26
|
+
// exactly the failure class the multi-vendor chain exists to remove. Vendors
|
|
27
|
+
// absent from this map (e.g. `custom`/`decodo`, whose credentials come from the
|
|
28
|
+
// `APIFUSE__PROXY__URL` bring-your-own escape hatch, not provider secrets) impose
|
|
29
|
+
// no declaration requirement.
|
|
30
|
+
const VENDOR_REQUIRED_SECRETS = {
|
|
31
|
+
smartproxy: [SMARTPROXY_APP_KEY_SECRET],
|
|
32
|
+
nodemaven: [NODEMAVEN_USERNAME_SECRET, NODEMAVEN_PASSWORD_SECRET],
|
|
33
|
+
};
|
|
20
34
|
const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
|
|
21
35
|
const MCP_TOOL_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
|
|
22
36
|
const VALID_OPERATION_RISK_CLASSES = ["read", "write", "destructive", "external-send"];
|
|
@@ -137,11 +151,21 @@ function validateProviderProxy(config) {
|
|
|
137
151
|
fix: `Use proxy: { mode: "required", provider: "smartproxy", geo: { country: "KR" }, session: { affinity: "connection", lifetimeMinutes: 30 } }`,
|
|
138
152
|
});
|
|
139
153
|
}
|
|
140
|
-
rejectUnknownFields(proxy, new Set(["mode", "provider", "geo", "session"]), "proxy");
|
|
154
|
+
rejectUnknownFields(proxy, new Set(["mode", "provider", "providers", "geo", "session"]), "proxy");
|
|
141
155
|
assertLiteralField(proxy.mode, "proxy.mode", VALID_PROVIDER_PROXY_MODES, config.id);
|
|
142
156
|
if (proxy.provider !== undefined) {
|
|
143
157
|
assertLiteralField(proxy.provider, "proxy.provider", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
|
|
144
158
|
}
|
|
159
|
+
if (proxy.providers !== undefined) {
|
|
160
|
+
if (!Array.isArray(proxy.providers) || proxy.providers.length === 0) {
|
|
161
|
+
throw new ValidationError(`Provider "${config.id}" has invalid proxy.providers: must be a non-empty array of proxy vendors.`, {
|
|
162
|
+
fix: `Use proxy.providers: ["smartproxy", "nodemaven"] to declare an ordered fallback chain.`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
for (const vendor of proxy.providers) {
|
|
166
|
+
assertLiteralField(vendor, "proxy.providers[]", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
145
169
|
if (proxy.geo !== undefined) {
|
|
146
170
|
if (!proxy.geo || typeof proxy.geo !== "object" || Array.isArray(proxy.geo)) {
|
|
147
171
|
throw new ValidationError(`Provider "${config.id}" has invalid proxy.geo: must be an object.`, {
|
|
@@ -178,14 +202,45 @@ function validateProviderProxy(config) {
|
|
|
178
202
|
throw new ValidationError(`Provider "${config.id}" has invalid proxy.session.poolSize: must be a positive integer.`);
|
|
179
203
|
}
|
|
180
204
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
205
|
+
// Every credentialed vendor in a required-mode chain must declare its
|
|
206
|
+
// provider secret(s) so a missing credential fails at build/validation time,
|
|
207
|
+
// not during a live outage. This covers the fallback legs too (not just the
|
|
208
|
+
// first vendor): a declared-but-uncredentialed nodemaven fallback would leave
|
|
209
|
+
// the chain silently down to a single vendor, reintroducing the SPOF the chain
|
|
210
|
+
// removes.
|
|
211
|
+
const vendorChain = proxy.providers && proxy.providers.length > 0
|
|
212
|
+
? proxy.providers
|
|
213
|
+
: proxy.provider
|
|
214
|
+
? [proxy.provider]
|
|
215
|
+
: [];
|
|
216
|
+
if (proxy.mode === "required") {
|
|
217
|
+
for (const vendor of vendorChain) {
|
|
218
|
+
const requiredSecrets = VENDOR_REQUIRED_SECRETS[vendor];
|
|
219
|
+
if (!requiredSecrets)
|
|
220
|
+
continue;
|
|
221
|
+
for (const secretName of requiredSecrets) {
|
|
222
|
+
// Match the canonical runtime gate (assertRequiredSecretsPresent /
|
|
223
|
+
// listMissingRequiredSecrets), which enforces only `required === true`
|
|
224
|
+
// declarations. A declaration that omits `required` (defaulting to
|
|
225
|
+
// optional) is skipped at runtime, so accepting it here would pass
|
|
226
|
+
// validation while leaving the credential unenforced until proxy
|
|
227
|
+
// resolution during a live request — the fail-open gap this check exists
|
|
228
|
+
// to close.
|
|
229
|
+
const declared = config.secrets?.some((secret) => secret.name === secretName && secret.required === true);
|
|
230
|
+
if (!declared) {
|
|
231
|
+
throw new ValidationError(`Provider "${config.id}" requires ${vendor} egress but does not declare ${secretName}.`, {
|
|
232
|
+
fix: `Add secrets: [{ name: "${secretName}", required: true }] to the provider (every vendor in a required proxy chain must declare its credential secrets).`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
187
236
|
}
|
|
188
237
|
}
|
|
238
|
+
// `decodo`/`custom` are deprecated vendor values (string-union members, so the
|
|
239
|
+
// @deprecated symbol gate can't catch them — warn at validation time instead).
|
|
240
|
+
const deprecatedVendors = vendorChain.filter((vendor) => vendor === "decodo" || vendor === "custom");
|
|
241
|
+
if (deprecatedVendors.length > 0) {
|
|
242
|
+
console.warn(`[provider-sdk] Provider "${config.id}" uses deprecated proxy vendor(s): ${deprecatedVendors.join(", ")}. Use "smartproxy"/"nodemaven", or the APIFUSE__PROXY__URL bring-your-own escape hatch.`);
|
|
243
|
+
}
|
|
189
244
|
}
|
|
190
245
|
function validateProviderStt(config) {
|
|
191
246
|
const stt = config.stt;
|
package/dist/runtime/choice.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
|
|
2
2
|
import { assertFreshProviderChoiceIssuedAt, ProviderChoiceTokenError, } from "../choice-token.js";
|
|
3
|
-
import { ProviderError } from "../errors.js";
|
|
3
|
+
import { isProviderError, ProviderError } from "../errors.js";
|
|
4
4
|
export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV = "APIFUSE__PROVIDER_RUNTIME__CHOICE_TOKEN_MASTER_SECRET";
|
|
5
5
|
const PRIMARY_CHOICE_TOKEN_KID = "v1";
|
|
6
6
|
const MANAGED_CHOICE_TOKEN_VERSION = 1;
|
|
@@ -209,7 +209,25 @@ async function parseServerStoredChoice(options) {
|
|
|
209
209
|
storage,
|
|
210
210
|
contextState: options.contextState,
|
|
211
211
|
});
|
|
212
|
-
|
|
212
|
+
// Reading a server-stored choice back deserializes a persisted value. A
|
|
213
|
+
// corrupt/undecodable value would otherwise surface as a raw JSON.parse
|
|
214
|
+
// SyntaxError (or another unexpected throwable) that escapes the choice error
|
|
215
|
+
// taxonomy, gets masked as internal_error 500, and is treated as retryable by
|
|
216
|
+
// the hub -> reservation restart loop (2026-07-22 catchtable RCA, candidate A).
|
|
217
|
+
// Convert any non-branded throwable into a branded invalid_payload so it maps
|
|
218
|
+
// to a clean, non-retryable 400. Branded ProviderChoiceTokenError and genuine
|
|
219
|
+
// ProviderError (e.g. Redis-unavailable / state-unavailable) pass through so
|
|
220
|
+
// their category/retryable semantics are preserved.
|
|
221
|
+
let record;
|
|
222
|
+
try {
|
|
223
|
+
record = await namespace.get(optionsStateKey(options.handle.state_id));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof ProviderChoiceTokenError || isProviderError(error)) {
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token state payload could not be decoded.");
|
|
230
|
+
}
|
|
213
231
|
if (!record) {
|
|
214
232
|
throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token state payload is missing.");
|
|
215
233
|
}
|
package/dist/runtime/http.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveProxyConfigAsync } from "../config/loader.js";
|
|
1
|
+
import { policyRotatesTransportVendorChain, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, } from "../config/loader.js";
|
|
2
2
|
import { ProviderError, TransportError } from "../errors.js";
|
|
3
3
|
import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
|
|
4
4
|
import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, isProxyTransportRetryMethod, normalizeProxyTransportRetryOptions, proxyTransportRetryErrorCode, proxyTransportRetryErrorStatus, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
|
|
@@ -7,6 +7,9 @@ const DEFAULT_HTTP_BASE_URL = "http://localhost";
|
|
|
7
7
|
function isHttpStatusOutcome(outcome) {
|
|
8
8
|
return "kind" in outcome && outcome.kind === "http-status";
|
|
9
9
|
}
|
|
10
|
+
function isDedupeSkipOutcome(outcome) {
|
|
11
|
+
return "kind" in outcome && outcome.kind === "dedupe-skip";
|
|
12
|
+
}
|
|
10
13
|
async function sleep(ms) {
|
|
11
14
|
if (ms <= 0)
|
|
12
15
|
return;
|
|
@@ -175,6 +178,9 @@ async function resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffs
|
|
|
175
178
|
baseProxyAttempt: clientOptions.proxyAttempt,
|
|
176
179
|
retryAttemptOffset: proxyAttemptOffset,
|
|
177
180
|
}),
|
|
181
|
+
// Bun's native fetch proxy option tunnels HTTP CONNECT only; SOCKS5 is not
|
|
182
|
+
// supported here, so a socks5 policy fails loudly rather than downgrading.
|
|
183
|
+
transportProtocols: ["http"],
|
|
178
184
|
telemetry: clientOptions.telemetry,
|
|
179
185
|
});
|
|
180
186
|
if (resolvedProxy.shouldWarn) {
|
|
@@ -196,7 +202,7 @@ function normalizeNativeFetchBody(body) {
|
|
|
196
202
|
copied.set(normalized);
|
|
197
203
|
return copied.buffer;
|
|
198
204
|
}
|
|
199
|
-
async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, warn, statusRetryCodes, proxyAttemptOffset = 0) {
|
|
205
|
+
async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, warn, statusRetryCodes, proxyAttemptOffset = 0, dedupe) {
|
|
200
206
|
const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
|
|
201
207
|
const controller = options.timeout ? new AbortController() : undefined;
|
|
202
208
|
const timeoutHandle = options.timeout
|
|
@@ -204,7 +210,20 @@ async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, war
|
|
|
204
210
|
: undefined;
|
|
205
211
|
let proxy;
|
|
206
212
|
try {
|
|
213
|
+
// Resolve inside the try (and after the timeout is armed) so allocator
|
|
214
|
+
// failures are branded as TransportErrors and count against the request
|
|
215
|
+
// deadline, exactly as an inline resolve would.
|
|
207
216
|
proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
|
|
217
|
+
// For a registry allocator chain, skip an endpoint a prior attempt already
|
|
218
|
+
// tried rather than re-issuing the same request. Returning the sentinel
|
|
219
|
+
// (instead of breaking) lets the loop keep advancing the flat offset until
|
|
220
|
+
// it crosses into the fallback vendor's pool span.
|
|
221
|
+
if (dedupe && proxy) {
|
|
222
|
+
if (dedupe.attempted.has(proxy)) {
|
|
223
|
+
return { kind: "dedupe-skip" };
|
|
224
|
+
}
|
|
225
|
+
dedupe.attempted.add(proxy);
|
|
226
|
+
}
|
|
208
227
|
const requestInit = {
|
|
209
228
|
headers: options.headers,
|
|
210
229
|
method,
|
|
@@ -325,22 +344,99 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
|
|
|
325
344
|
const attemptOptions = statusRetryEnabled
|
|
326
345
|
? { ...headersOptions, throwOnHttpError: false }
|
|
327
346
|
: headersOptions;
|
|
328
|
-
|
|
347
|
+
// Span the whole vendor chain on transport failures. Like ctx.stealth, a
|
|
348
|
+
// policy-managed proxy resolves a *different* endpoint/vendor per attempt
|
|
349
|
+
// (the flat proxyAttemptOffset rotates across the concatenated vendor pool
|
|
350
|
+
// spans), so a transport failure should advance to the next endpoint —
|
|
351
|
+
// potentially crossing into the fallback vendor — rather than stopping at
|
|
352
|
+
// the per-endpoint retry budget and stranding the request on the primary
|
|
353
|
+
// vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
|
|
354
|
+
// only for implicit, safe-method allocator requests; explicit retry
|
|
355
|
+
// policies (their documented `attempts` ceiling), unsafe methods, and
|
|
356
|
+
// static/non-registry vendors keep the retry budget. Status-code retries
|
|
357
|
+
// stay bounded by the retry budget regardless; only transport rotation gets
|
|
358
|
+
// the full span.
|
|
359
|
+
const policyProxy = (() => {
|
|
360
|
+
const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
|
|
361
|
+
return policy && typeof policy === "object" ? policy : undefined;
|
|
362
|
+
})();
|
|
363
|
+
const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
|
|
364
|
+
const transportAttemptCap = retryOptions
|
|
365
|
+
? resolvePolicyTransportAttemptCap({
|
|
366
|
+
policy: policyProxy,
|
|
367
|
+
usesPolicyAllocator,
|
|
368
|
+
retryAttempts: retryOptions.attempts,
|
|
369
|
+
explicitRetry,
|
|
370
|
+
method: methodName,
|
|
371
|
+
})
|
|
372
|
+
: 1;
|
|
373
|
+
// Track resolved endpoints across a policy-allocator chain. Successive
|
|
374
|
+
// attempts rotate the flat offset across the concatenated vendor pool
|
|
375
|
+
// spans, but an under-filled allocation (fewer live endpoints than the
|
|
376
|
+
// configured pool size) makes the modulo mapping repeat endpoints before
|
|
377
|
+
// the offset reaches the next vendor. Rather than re-hammering an
|
|
378
|
+
// already-tried endpoint under backoff, fetchNativeHttp returns a skip
|
|
379
|
+
// sentinel for a duplicate; the loop then advances the flat offset without
|
|
380
|
+
// issuing the request, so it keeps walking toward — and into — the fallback
|
|
381
|
+
// vendor's pool span instead of stalling on the primary vendor.
|
|
382
|
+
// De-duplication is gated on the SAME predicate that widens the attempt cap
|
|
383
|
+
// (implicit, safe-method, registry-chain rotation). It must NOT engage for
|
|
384
|
+
// an explicit retry policy: there the caller's `attempts` count is the
|
|
385
|
+
// contract and each attempt must issue against whatever endpoint it resolves
|
|
386
|
+
// — even a repeat — instead of being silently skipped (which would collapse
|
|
387
|
+
// a `poolSize: 1` + `attempts: 3` request to a single fetch).
|
|
388
|
+
const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
|
|
389
|
+
policy: policyProxy,
|
|
390
|
+
usesPolicyAllocator,
|
|
391
|
+
explicitRetry,
|
|
392
|
+
method: methodName,
|
|
393
|
+
});
|
|
394
|
+
const dedupeContext = dedupeAllocatorEndpoints
|
|
395
|
+
? { attempted: new Set() }
|
|
396
|
+
: undefined;
|
|
397
|
+
const executeOnce = (proxyAttemptOffset = 0) => fetchNativeHttp(baseUrl, url, methodName, attemptOptions, clientOptions, warnOnce, statusRetryEnabled ? retryOptions?.statusCodes : undefined, proxyAttemptOffset, dedupeContext);
|
|
329
398
|
if (!retryEnabled || !retryOptions) {
|
|
330
399
|
const outcome = await executeOnce();
|
|
400
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
401
|
+
// Single-shot path never de-duplicates (dedupeContext is undefined),
|
|
402
|
+
// but keep the union total.
|
|
403
|
+
throw new TransportError("HTTP request produced no terminal result", {
|
|
404
|
+
code: "retry_exhausted",
|
|
405
|
+
});
|
|
406
|
+
}
|
|
331
407
|
if (isHttpStatusOutcome(outcome)) {
|
|
332
408
|
throw toUpstreamHttpError(outcome.status);
|
|
333
409
|
}
|
|
334
410
|
return outcome;
|
|
335
411
|
}
|
|
412
|
+
let lastError;
|
|
336
413
|
let lastErrorCode;
|
|
337
414
|
let lastStatus;
|
|
338
|
-
|
|
415
|
+
// `attempt` walks the flat proxy offset across the full chain span; `issued`
|
|
416
|
+
// counts requests that were actually sent (skipped duplicate offsets do not
|
|
417
|
+
// increment it). Retry summaries and the status-retry budget must reflect
|
|
418
|
+
// issued requests, not the raw offset, so they stay accurate when partial
|
|
419
|
+
// allocations skip offsets.
|
|
420
|
+
let issued = 0;
|
|
421
|
+
for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
|
|
422
|
+
// Whether this offset actually issued a request (vs. a skipped duplicate),
|
|
423
|
+
// so the catch counts a thrown *transport* failure once without
|
|
424
|
+
// double-counting a status outcome that already incremented before it
|
|
425
|
+
// re-threw as an upstream HTTP error.
|
|
426
|
+
let issuedThisAttempt = false;
|
|
339
427
|
try {
|
|
340
428
|
const outcome = await executeOnce(attempt - 1);
|
|
429
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
430
|
+
// Duplicate endpoint from a partial allocation: advance the flat
|
|
431
|
+
// offset without issuing the request (no backoff, not a failure) so
|
|
432
|
+
// the loop keeps rotating toward the fallback vendor.
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
issued += 1;
|
|
436
|
+
issuedThisAttempt = true;
|
|
341
437
|
if (isHttpStatusOutcome(outcome)) {
|
|
342
438
|
lastStatus = outcome.status;
|
|
343
|
-
if (outcome.retryable &&
|
|
439
|
+
if (outcome.retryable && issued < retryOptions.attempts) {
|
|
344
440
|
await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
|
|
345
441
|
continue;
|
|
346
442
|
}
|
|
@@ -350,10 +446,10 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
|
|
|
350
446
|
if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
|
|
351
447
|
throw toUpstreamHttpError(response.status);
|
|
352
448
|
}
|
|
353
|
-
if (
|
|
449
|
+
if (issued > 1) {
|
|
354
450
|
const summary = {
|
|
355
|
-
attempts:
|
|
356
|
-
retries:
|
|
451
|
+
attempts: issued,
|
|
452
|
+
retries: issued - 1,
|
|
357
453
|
...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
|
|
358
454
|
transport: "native",
|
|
359
455
|
...(lastErrorCode ? { lastErrorCode } : {}),
|
|
@@ -364,10 +460,13 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
|
|
|
364
460
|
return response;
|
|
365
461
|
}
|
|
366
462
|
catch (error) {
|
|
463
|
+
if (!issuedThisAttempt)
|
|
464
|
+
issued += 1;
|
|
465
|
+
lastError = error;
|
|
367
466
|
lastErrorCode = proxyTransportRetryErrorCode(error);
|
|
368
467
|
lastStatus = proxyTransportRetryErrorStatus(error);
|
|
369
468
|
const proxyUsed = Boolean(error.proxyUsed);
|
|
370
|
-
if (attempt <
|
|
469
|
+
if (attempt < transportAttemptCap &&
|
|
371
470
|
shouldRetryProxyTransportAttempt({
|
|
372
471
|
error,
|
|
373
472
|
explicitRetry,
|
|
@@ -381,6 +480,13 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
|
|
|
381
480
|
throw error;
|
|
382
481
|
}
|
|
383
482
|
}
|
|
483
|
+
// Reached when the attempt cap is consumed without a terminal outcome —
|
|
484
|
+
// e.g. the final offsets of a partial allocation all resolved to
|
|
485
|
+
// already-tried endpoints and were skipped. Surface the last real transport
|
|
486
|
+
// failure rather than a synthetic exhaustion error.
|
|
487
|
+
if (lastError !== undefined) {
|
|
488
|
+
throw lastError;
|
|
489
|
+
}
|
|
384
490
|
throw new TransportError("HTTP retry exhausted without a terminal result", {
|
|
385
491
|
code: "retry_exhausted",
|
|
386
492
|
});
|
|
@@ -11,8 +11,12 @@ const PROXY_POOL_STALE_STATUS_CODES = new Set([509, 512]);
|
|
|
11
11
|
const PROXY_EDGE_TLS_REJECTED_STATUS_CODES = new Set([495]);
|
|
12
12
|
const PROXY_AUTH_IP_DENIED_PATTERN = /\b(?:source|egress|client)\s+ip\b.{0,120}\b(?:deny|denied|unauthori[sz]ed|not\s+authori[sz]ed|white\s*list|allow\s*list)\b|\b(?:white\s*list|allow\s*list)\b.{0,120}\b(?:source|egress|client)\s+ip\b/i;
|
|
13
13
|
const PROXY_EDGE_AUTH_REJECTED_PATTERN = /\bauth\s+ip\s+err\b|\bproxy\b.{0,120}\bauth(?:entication)?\b.{0,120}\b(?:reject(?:ed)?|fail(?:ed)?|invalid|den(?:y|ied)|unauthori[sz]ed)\b|\bauth(?:entication)?\b.{0,120}\b(?:reject(?:ed)?|fail(?:ed)?|invalid|den(?:y|ied)|unauthori[sz]ed)\b.{0,120}\bproxy\b/i;
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Vendor host tokens that can appear in upstream error strings. Adding a proxy
|
|
15
|
+
// vendor updates every classifier below in one place. `proxy` is the generic
|
|
16
|
+
// fallback so vendor-agnostic messages still classify.
|
|
17
|
+
const PROXY_VENDOR_ALTERNATION = "smartproxy|nodemaven|proxy";
|
|
18
|
+
const PROXY_POOL_STALE_MESSAGE_PATTERN = new RegExp(`\\bproxy\\b.{0,120}\\b(?:pool|lease|expired|unavailable|exhausted|non[\\s-]?200\\s+code:\\s*(?:509|512))\\b|\\bnon[\\s-]?200\\s+code:\\s*(?:509|512)\\b.{0,120}\\bproxy\\b|\\b(?:${PROXY_VENDOR_ALTERNATION})\\b.{0,120}\\b(?:509|512)\\b`, "i");
|
|
19
|
+
const PROXY_EDGE_TLS_REJECTED_MESSAGE_PATTERN = new RegExp(`\\b(?:${PROXY_VENDOR_ALTERNATION})\\b.{0,160}\\b(?:495|ssl|tls|cert(?:ificate)?|handshake|edge|connect|non[\\s-]?200)\\b|\\b(?:495|ssl|tls|cert(?:ificate)?|handshake|edge|connect|non[\\s-]?200)\\b.{0,160}\\b(?:${PROXY_VENDOR_ALTERNATION})\\b`, "i");
|
|
16
20
|
export function isProxyAuthIpDeniedMessage(message) {
|
|
17
21
|
return PROXY_AUTH_IP_DENIED_PATTERN.test(message);
|
|
18
22
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ProviderProxyPolicy } from "../types.js";
|
|
2
|
+
export declare const NODEMAVEN_USERNAME_ENV = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
|
|
3
|
+
export declare const NODEMAVEN_PASSWORD_ENV = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
|
|
4
|
+
export declare const NODEMAVEN_FILTER_ENV = "APIFUSE__PROXY__NODEMAVEN_FILTER";
|
|
5
|
+
export declare const NODEMAVEN_GATEWAY_HOST = "gate.nodemaven.com";
|
|
6
|
+
/** Both schemes tunnel bytes end-to-end, preserving the client TLS handshake. */
|
|
7
|
+
export type ProxyProtocol = "http" | "socks5";
|
|
8
|
+
/**
|
|
9
|
+
* NodeMaven's fastest protocol: HTTP CONNECT. Benchmarks (KR, cold + warm)
|
|
10
|
+
* showed socks5 through the gateway adds ~500ms per request over http, so
|
|
11
|
+
* NodeMaven never defaults to socks5.
|
|
12
|
+
*/
|
|
13
|
+
export declare const NODEMAVEN_DEFAULT_PROTOCOL: ProxyProtocol;
|
|
14
|
+
export declare const NODEMAVEN_MAX_POOL_SIZE = 50;
|
|
15
|
+
export declare function hasNodemavenCredentials(): boolean;
|
|
16
|
+
export declare function nodemavenPoolSize(policy: ProviderProxyPolicy): number;
|
|
17
|
+
export type NodemavenSynthesisInput = {
|
|
18
|
+
policy: ProviderProxyPolicy;
|
|
19
|
+
affinityKey: string | undefined;
|
|
20
|
+
protocol: ProxyProtocol;
|
|
21
|
+
poolIndex: number;
|
|
22
|
+
refreshEpoch: number;
|
|
23
|
+
/** ISO 3166-1 alpha-2, already resolved by the caller (falls back to env). */
|
|
24
|
+
country?: string;
|
|
25
|
+
};
|
|
26
|
+
export type NodemavenSynthesis = {
|
|
27
|
+
url: string;
|
|
28
|
+
protocol: ProxyProtocol;
|
|
29
|
+
diagnostics: Record<string, string | number | boolean>;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Synthesize a NodeMaven gateway proxy URL locally from static credentials.
|
|
33
|
+
* There is no allocation API — geo/session are encoded in the username.
|
|
34
|
+
*/
|
|
35
|
+
export declare function synthesizeNodemavenProxy(input: NodemavenSynthesisInput): NodemavenSynthesis;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
export const NODEMAVEN_USERNAME_ENV = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
|
|
3
|
+
export const NODEMAVEN_PASSWORD_ENV = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
|
|
4
|
+
export const NODEMAVEN_FILTER_ENV = "APIFUSE__PROXY__NODEMAVEN_FILTER";
|
|
5
|
+
export const NODEMAVEN_GATEWAY_HOST = "gate.nodemaven.com";
|
|
6
|
+
/**
|
|
7
|
+
* NodeMaven's fastest protocol: HTTP CONNECT. Benchmarks (KR, cold + warm)
|
|
8
|
+
* showed socks5 through the gateway adds ~500ms per request over http, so
|
|
9
|
+
* NodeMaven never defaults to socks5.
|
|
10
|
+
*/
|
|
11
|
+
export const NODEMAVEN_DEFAULT_PROTOCOL = "http";
|
|
12
|
+
/** NodeMaven gateway port ranges per protocol (docs: HTTP 8080-9080, SOCKS5 1080-2080). */
|
|
13
|
+
const NODEMAVEN_PORTS = {
|
|
14
|
+
http: { min: 8080, max: 9080 },
|
|
15
|
+
socks5: { min: 1080, max: 2080 },
|
|
16
|
+
};
|
|
17
|
+
const NODEMAVEN_FILTERS = new Set(["medium", "high"]);
|
|
18
|
+
const DEFAULT_NODEMAVEN_FILTER = "medium";
|
|
19
|
+
const DEFAULT_NODEMAVEN_POOL_SIZE = 20;
|
|
20
|
+
export const NODEMAVEN_MAX_POOL_SIZE = 50;
|
|
21
|
+
/** NodeMaven sticky sessions persist up to 24h server-side, keyed by the sid. */
|
|
22
|
+
const NODEMAVEN_MAX_LIFETIME_MINUTES = 1440;
|
|
23
|
+
const SID_LENGTH = 10;
|
|
24
|
+
export function hasNodemavenCredentials() {
|
|
25
|
+
return Boolean(readNodemavenUsername() && readNodemavenPassword());
|
|
26
|
+
}
|
|
27
|
+
function readNodemavenUsername() {
|
|
28
|
+
return process.env[NODEMAVEN_USERNAME_ENV]?.trim() || undefined;
|
|
29
|
+
}
|
|
30
|
+
function readNodemavenPassword() {
|
|
31
|
+
return process.env[NODEMAVEN_PASSWORD_ENV]?.trim() || undefined;
|
|
32
|
+
}
|
|
33
|
+
function resolveNodemavenFilter() {
|
|
34
|
+
const raw = process.env[NODEMAVEN_FILTER_ENV]?.trim().toLowerCase();
|
|
35
|
+
if (!raw)
|
|
36
|
+
return DEFAULT_NODEMAVEN_FILTER;
|
|
37
|
+
if (!NODEMAVEN_FILTERS.has(raw)) {
|
|
38
|
+
throw new Error(`${NODEMAVEN_FILTER_ENV} must be "medium" or "high"`);
|
|
39
|
+
}
|
|
40
|
+
return raw;
|
|
41
|
+
}
|
|
42
|
+
export function nodemavenPoolSize(policy) {
|
|
43
|
+
return Math.min(NODEMAVEN_MAX_POOL_SIZE, Math.max(1, Math.floor(policy.session?.poolSize ?? DEFAULT_NODEMAVEN_POOL_SIZE)));
|
|
44
|
+
}
|
|
45
|
+
function nodemavenLifetimeMinutes(policy) {
|
|
46
|
+
const configured = policy.session?.lifetimeMinutes;
|
|
47
|
+
if (typeof configured !== "number" || !Number.isFinite(configured) || configured <= 0) {
|
|
48
|
+
return NODEMAVEN_MAX_LIFETIME_MINUTES;
|
|
49
|
+
}
|
|
50
|
+
return Math.min(NODEMAVEN_MAX_LIFETIME_MINUTES, Math.max(1, Math.floor(configured)));
|
|
51
|
+
}
|
|
52
|
+
/** NodeMaven username tokens accept `[a-z0-9]`; slugify geo values to that set. */
|
|
53
|
+
function slugifyGeo(value) {
|
|
54
|
+
if (!value)
|
|
55
|
+
return undefined;
|
|
56
|
+
const slug = value
|
|
57
|
+
.trim()
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9]+/g, "");
|
|
60
|
+
return slug || undefined;
|
|
61
|
+
}
|
|
62
|
+
function isStickyAffinity(policy) {
|
|
63
|
+
return (policy.session?.affinity ?? "request") !== "request";
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A sticky sid is deterministic from the affinity key so every process serving
|
|
67
|
+
* the same connection derives the same egress IP without shared storage. A
|
|
68
|
+
* rotating sid is random per call (a fresh egress IP per request).
|
|
69
|
+
*/
|
|
70
|
+
function deriveSid(policy, affinityKey, poolIndex, refreshEpoch) {
|
|
71
|
+
if (!isStickyAffinity(policy) || !affinityKey) {
|
|
72
|
+
return randomBytes(SID_LENGTH).toString("hex").slice(0, SID_LENGTH);
|
|
73
|
+
}
|
|
74
|
+
const digest = createHash("sha256")
|
|
75
|
+
.update(`${affinityKey}:${refreshEpoch}:${poolIndex}`)
|
|
76
|
+
.digest("hex");
|
|
77
|
+
// hex digits are a subset of the allowed [a-z0-9] sid charset.
|
|
78
|
+
return digest.slice(0, SID_LENGTH);
|
|
79
|
+
}
|
|
80
|
+
function selectPort(protocol, sid, poolIndex) {
|
|
81
|
+
const { min, max } = NODEMAVEN_PORTS[protocol];
|
|
82
|
+
const span = max - min + 1;
|
|
83
|
+
const hashInt = Number.parseInt(createHash("sha256").update(`${sid}:${poolIndex}`).digest("hex").slice(0, 8), 16);
|
|
84
|
+
return min + (hashInt % span);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Synthesize a NodeMaven gateway proxy URL locally from static credentials.
|
|
88
|
+
* There is no allocation API — geo/session are encoded in the username.
|
|
89
|
+
*/
|
|
90
|
+
export function synthesizeNodemavenProxy(input) {
|
|
91
|
+
const username = readNodemavenUsername();
|
|
92
|
+
const password = readNodemavenPassword();
|
|
93
|
+
if (!username || !password) {
|
|
94
|
+
throw new Error(`NodeMaven credentials missing: set ${NODEMAVEN_USERNAME_ENV} and ${NODEMAVEN_PASSWORD_ENV}.`);
|
|
95
|
+
}
|
|
96
|
+
const filter = resolveNodemavenFilter();
|
|
97
|
+
const sid = deriveSid(input.policy, input.affinityKey, input.poolIndex, input.refreshEpoch);
|
|
98
|
+
const port = selectPort(input.protocol, sid, input.poolIndex);
|
|
99
|
+
const lifetimeMinutes = nodemavenLifetimeMinutes(input.policy);
|
|
100
|
+
const country = slugifyGeo(input.country ?? input.policy.geo?.country);
|
|
101
|
+
const region = slugifyGeo(input.policy.geo?.subdivision);
|
|
102
|
+
const city = slugifyGeo(input.policy.geo?.city);
|
|
103
|
+
const tokens = [username];
|
|
104
|
+
if (country)
|
|
105
|
+
tokens.push("country", country);
|
|
106
|
+
if (region)
|
|
107
|
+
tokens.push("region", region);
|
|
108
|
+
if (city)
|
|
109
|
+
tokens.push("city", city);
|
|
110
|
+
tokens.push("sid", sid);
|
|
111
|
+
tokens.push("filter", filter);
|
|
112
|
+
tokens.push("ipv4", "true");
|
|
113
|
+
const proxyUsername = tokens.join("-");
|
|
114
|
+
// Username tokens are [a-z0-9-] only, which survive URL encoding unchanged.
|
|
115
|
+
const url = `${input.protocol}://${proxyUsername}:${encodeURIComponent(password)}@${NODEMAVEN_GATEWAY_HOST}:${port}`;
|
|
116
|
+
return {
|
|
117
|
+
url,
|
|
118
|
+
protocol: input.protocol,
|
|
119
|
+
diagnostics: {
|
|
120
|
+
vendor: "nodemaven",
|
|
121
|
+
protocol: input.protocol,
|
|
122
|
+
sticky: isStickyAffinity(input.policy),
|
|
123
|
+
filter,
|
|
124
|
+
lifetimeMinutes,
|
|
125
|
+
...(country ? { country } : {}),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { ProxyAttemptTelemetryEvent, ProxyResolutionTelemetryEvent, ProxyTelemetrySink } from "../config/loader.js";
|
|
1
|
+
import type { ProxyAttemptTelemetryEvent, ProxyResolutionTelemetryEvent, ProxyTelemetrySink, ProxyVendorFailoverTelemetryEvent } from "../config/loader.js";
|
|
2
2
|
export declare const PROVIDER_TELEMETRY_HEADER = "X-ApiFuse-Provider-Telemetry";
|
|
3
3
|
export declare class ProxyTelemetryCollector implements ProxyTelemetrySink {
|
|
4
4
|
#private;
|
|
5
5
|
recordProxyResolution(event: ProxyResolutionTelemetryEvent): void;
|
|
6
|
+
recordProxyVendorFailover(event: ProxyVendorFailoverTelemetryEvent): void;
|
|
6
7
|
recordProxyAttempt(event: ProxyAttemptTelemetryEvent): void;
|
|
7
8
|
toHeaderValue(): string | undefined;
|
|
8
9
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export const PROVIDER_TELEMETRY_HEADER = "X-ApiFuse-Provider-Telemetry";
|
|
2
2
|
const MAX_HEADER_BYTES = 4_096;
|
|
3
3
|
const MAX_PROXY_ATTEMPT_SAMPLES = 24;
|
|
4
|
+
const MAX_PROXY_FAILOVER_SAMPLES = 12;
|
|
4
5
|
const CACHE_STATUS_SEVERITY = {
|
|
5
6
|
disabled: 0,
|
|
6
7
|
memory_hit: 1,
|
|
@@ -28,9 +29,11 @@ function encodeBase64Url(value) {
|
|
|
28
29
|
export class ProxyTelemetryCollector {
|
|
29
30
|
#events = [];
|
|
30
31
|
#attempts = [];
|
|
32
|
+
#failovers = [];
|
|
31
33
|
recordProxyResolution(event) {
|
|
32
34
|
this.#events.push({
|
|
33
|
-
provider:
|
|
35
|
+
provider: event.provider,
|
|
36
|
+
...(event.protocol ? { protocol: event.protocol } : {}),
|
|
34
37
|
cacheStatus: event.cacheStatus,
|
|
35
38
|
cacheHit: event.cacheHit,
|
|
36
39
|
resolutionMs: Math.max(0, Math.floor(event.resolutionMs)),
|
|
@@ -53,11 +56,22 @@ export class ProxyTelemetryCollector {
|
|
|
53
56
|
refreshes: event.refreshes === undefined ? undefined : Math.max(0, Math.floor(event.refreshes)),
|
|
54
57
|
});
|
|
55
58
|
}
|
|
59
|
+
recordProxyVendorFailover(event) {
|
|
60
|
+
if (this.#failovers.length >= MAX_PROXY_FAILOVER_SAMPLES)
|
|
61
|
+
return;
|
|
62
|
+
this.#failovers.push({
|
|
63
|
+
vendor: event.vendor,
|
|
64
|
+
...(event.nextVendor ? { nextVendor: event.nextVendor } : {}),
|
|
65
|
+
phase: event.phase,
|
|
66
|
+
reason: event.reason,
|
|
67
|
+
...(event.attempt === undefined ? {} : { attempt: Math.max(0, Math.floor(event.attempt)) }),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
56
70
|
recordProxyAttempt(event) {
|
|
57
71
|
if (this.#attempts.length >= MAX_PROXY_ATTEMPT_SAMPLES)
|
|
58
72
|
return;
|
|
59
73
|
this.#attempts.push({
|
|
60
|
-
provider:
|
|
74
|
+
provider: event.provider,
|
|
61
75
|
attempt: Math.max(1, Math.floor(event.attempt || 1)),
|
|
62
76
|
...(event.poolIndex === undefined
|
|
63
77
|
? {}
|
|
@@ -75,8 +89,16 @@ export class ProxyTelemetryCollector {
|
|
|
75
89
|
const [first, ...rest] = this.#events;
|
|
76
90
|
if (!first)
|
|
77
91
|
return undefined;
|
|
92
|
+
// The serving vendor/protocol is the last recorded resolution (a failed
|
|
93
|
+
// vendor records first, the vendor that served records last).
|
|
94
|
+
const serving = this.#events[this.#events.length - 1] ?? first;
|
|
95
|
+
const vendors = [];
|
|
96
|
+
for (const event of this.#events) {
|
|
97
|
+
if (!vendors.includes(event.provider))
|
|
98
|
+
vendors.push(event.provider);
|
|
99
|
+
}
|
|
78
100
|
const aggregate = rest.reduce((acc, event) => ({
|
|
79
|
-
provider:
|
|
101
|
+
provider: event.provider,
|
|
80
102
|
cacheStatus: worseStatus(acc.cacheStatus, event.cacheStatus),
|
|
81
103
|
cacheHit: acc.cacheHit && event.cacheHit,
|
|
82
104
|
resolutionMs: acc.resolutionMs + event.resolutionMs,
|
|
@@ -95,7 +117,8 @@ export class ProxyTelemetryCollector {
|
|
|
95
117
|
const payload = {
|
|
96
118
|
v: 1,
|
|
97
119
|
proxy: {
|
|
98
|
-
provider:
|
|
120
|
+
provider: serving.provider,
|
|
121
|
+
...(serving.protocol ? { protocol: serving.protocol } : {}),
|
|
99
122
|
cacheStatus: aggregate.cacheStatus,
|
|
100
123
|
cacheHit: aggregate.cacheHit,
|
|
101
124
|
resolutionMs: aggregate.resolutionMs,
|
|
@@ -132,6 +155,18 @@ export class ProxyTelemetryCollector {
|
|
|
132
155
|
})),
|
|
133
156
|
}
|
|
134
157
|
: {}),
|
|
158
|
+
...(vendors.length > 1 ? { vendors } : {}),
|
|
159
|
+
...(this.#failovers.length > 0
|
|
160
|
+
? {
|
|
161
|
+
failovers: this.#failovers.map((failover) => ({
|
|
162
|
+
v: failover.vendor,
|
|
163
|
+
...(failover.nextVendor ? { nx: failover.nextVendor } : {}),
|
|
164
|
+
p: failover.phase,
|
|
165
|
+
r: failover.reason,
|
|
166
|
+
...(failover.attempt === undefined ? {} : { a: failover.attempt }),
|
|
167
|
+
})),
|
|
168
|
+
}
|
|
169
|
+
: {}),
|
|
135
170
|
},
|
|
136
171
|
};
|
|
137
172
|
const encoded = encodeBase64Url(JSON.stringify(payload));
|
package/dist/runtime/state.js
CHANGED
|
@@ -69,7 +69,18 @@ function resolveExpiresAt(ttl) {
|
|
|
69
69
|
function envelopeFromJson(key, raw) {
|
|
70
70
|
if (!raw)
|
|
71
71
|
return null;
|
|
72
|
-
|
|
72
|
+
// A corrupt/undecodable persisted envelope must be treated as absent rather
|
|
73
|
+
// than throwing a raw JSON.parse SyntaxError: an uncaught SyntaxError escapes
|
|
74
|
+
// the provider error taxonomy, is masked as internal_error 500, and is then
|
|
75
|
+
// retried by the hub (2026-07-22 catchtable reserve RCA, candidate A). Returning
|
|
76
|
+
// null also keeps list() from aborting the whole scan on a single bad entry.
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
73
84
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
74
85
|
return null;
|
|
75
86
|
}
|