@apifuse/provider-sdk 2.2.0-beta.47 → 2.2.0-beta.49

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.
Files changed (114) hide show
  1. package/AUTHORING.md +91 -36
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +11 -9
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-dev.ts +24 -13
  6. package/bin/apifuse-migrate-operation-declaration.ts +55 -0
  7. package/bin/apifuse-pack-smoke.ts +1 -1
  8. package/bin/apifuse-pack-types.ts +2 -1
  9. package/bin/apifuse-record.ts +30 -16
  10. package/bin/apifuse-submit-check.ts +20 -35
  11. package/dist/cli/commands.d.ts +1 -1
  12. package/dist/cli/commands.js +11 -0
  13. package/dist/cli/migrate-operation-declaration.d.ts +59 -0
  14. package/dist/cli/migrate-operation-declaration.js +1178 -0
  15. package/dist/cli/templates/provider/README.md.tpl +3 -3
  16. package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -0
  17. package/dist/config/loader.d.ts +2 -0
  18. package/dist/config/loader.js +18 -7
  19. package/dist/contract-types.d.ts +11 -5
  20. package/dist/contract.js +21 -10
  21. package/dist/define.d.ts +25 -22
  22. package/dist/define.js +49 -75
  23. package/dist/dev.d.ts +3 -0
  24. package/dist/dev.js +1 -1
  25. package/dist/engine.d.ts +78 -0
  26. package/dist/engine.js +133 -0
  27. package/dist/index.d.ts +3 -2
  28. package/dist/index.js +2 -1
  29. package/dist/lint.d.ts +7 -15
  30. package/dist/lint.js +45 -70
  31. package/dist/provider.d.ts +3 -1
  32. package/dist/provider.js +1 -0
  33. package/dist/runtime/chrome149-header-order.d.ts +58 -0
  34. package/dist/runtime/chrome149-header-order.js +289 -0
  35. package/dist/runtime/env.js +12 -0
  36. package/dist/runtime/executor.d.ts +2 -1
  37. package/dist/runtime/executor.js +3 -36
  38. package/dist/runtime/insights.js +2 -2
  39. package/dist/runtime/otlp.d.ts +71 -2
  40. package/dist/runtime/otlp.js +397 -16
  41. package/dist/runtime/resolver-public.d.ts +1 -1
  42. package/dist/runtime/resolver-public.js +1 -1
  43. package/dist/runtime/resolver-vendors/capsolver.js +9 -3
  44. package/dist/runtime/resolver-vendors/twocaptcha.js +1 -0
  45. package/dist/runtime/resolver.d.ts +12 -0
  46. package/dist/runtime/resolver.js +45 -11
  47. package/dist/runtime/stealth.d.ts +13 -4
  48. package/dist/runtime/stealth.js +362 -85
  49. package/dist/runtime/trace-config.js +2 -1
  50. package/dist/runtime/trace.d.ts +5 -0
  51. package/dist/runtime/trace.js +43 -10
  52. package/dist/server/self-test.d.ts +1 -3
  53. package/dist/server/self-test.js +2 -12
  54. package/dist/server/serve-implementation.d.ts +6 -1
  55. package/dist/server/serve-implementation.js +55 -40
  56. package/dist/server/trace-output.d.ts +3 -1
  57. package/dist/server/trace-output.js +61 -2
  58. package/dist/stealth/profiles.d.ts +9 -8
  59. package/dist/stealth/profiles.js +123 -286
  60. package/dist/types.d.ts +116 -108
  61. package/package.json +2 -1
  62. package/src/cli/__tests__/fixtures/migrate-operation-declaration/approval-override.ts.txt +6 -0
  63. package/src/cli/__tests__/fixtures/migrate-operation-declaration/codemod-syntax.ts.txt +3 -0
  64. package/src/cli/__tests__/fixtures/migrate-operation-declaration/connection-precedence.ts.txt +10 -0
  65. package/src/cli/__tests__/fixtures/migrate-operation-declaration/docs-conflict.ts.txt +8 -0
  66. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-map.ts.txt +5 -0
  67. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-operation.ts.txt +16 -0
  68. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-map.ts.txt +3 -0
  69. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoist-all.ts.txt +31 -0
  70. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoisted-const.ts.txt +11 -0
  71. package/src/cli/__tests__/fixtures/migrate-operation-declaration/imported-spread.ts.txt +11 -0
  72. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-map.ts.txt +11 -0
  73. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-cast-tail.ts.txt +21 -0
  74. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-ekitan.ts.txt +11 -0
  75. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-override.ts.txt +14 -0
  76. package/src/cli/__tests__/fixtures/migrate-operation-declaration/missing-english-locale.ts.txt +7 -0
  77. package/src/cli/__tests__/fixtures/migrate-operation-declaration/no-safety.ts.txt +6 -0
  78. package/src/cli/__tests__/fixtures/migrate-operation-declaration/non-literal.ts.txt +7 -0
  79. package/src/cli/__tests__/fixtures/migrate-operation-declaration/redundant-approval.ts.txt +6 -0
  80. package/src/cli/__tests__/fixtures/migrate-operation-declaration/safety-conflict.ts.txt +7 -0
  81. package/src/cli/__tests__/fixtures/migrate-operation-declaration/stream.ts.txt +7 -0
  82. package/src/cli/__tests__/fixtures/migrate-operation-declaration/tool-router-spread.ts.txt +15 -0
  83. package/src/cli/__tests__/fixtures/migrate-operation-declaration/unparseable.ts.txt +4 -0
  84. package/src/cli/__tests__/fixtures/migrate-operation-declaration/verbatim-template.ts.txt +12 -0
  85. package/src/cli/commands.ts +13 -0
  86. package/src/cli/migrate-operation-declaration.ts +1654 -0
  87. package/src/cli/templates/provider/README.md.tpl +3 -3
  88. package/src/cli/templates/provider/operations/ping.ts.tpl +2 -0
  89. package/src/config/loader.ts +31 -6
  90. package/src/contract-types.ts +11 -5
  91. package/src/contract.ts +21 -10
  92. package/src/define.ts +107 -119
  93. package/src/dev.ts +4 -1
  94. package/src/engine.ts +279 -0
  95. package/src/index.ts +13 -5
  96. package/src/lint.ts +58 -92
  97. package/src/provider.ts +25 -3
  98. package/src/runtime/chrome149-header-order.ts +330 -0
  99. package/src/runtime/env.ts +13 -0
  100. package/src/runtime/executor.ts +7 -40
  101. package/src/runtime/insights.ts +2 -2
  102. package/src/runtime/otlp.ts +467 -21
  103. package/src/runtime/resolver-public.ts +3 -0
  104. package/src/runtime/resolver-vendors/capsolver.ts +12 -4
  105. package/src/runtime/resolver-vendors/twocaptcha.ts +1 -0
  106. package/src/runtime/resolver.ts +68 -19
  107. package/src/runtime/stealth.ts +435 -103
  108. package/src/runtime/trace-config.ts +3 -2
  109. package/src/runtime/trace.ts +57 -17
  110. package/src/server/self-test.ts +2 -9
  111. package/src/server/serve-implementation.ts +89 -72
  112. package/src/server/trace-output.ts +99 -2
  113. package/src/stealth/profiles.ts +169 -327
  114. package/src/types.ts +114 -137
@@ -1,3 +1,183 @@
1
+ import { AsyncResource } from "node:async_hooks";
2
+ export const OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT";
3
+ export const OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT";
4
+ export const OTEL_EXPORTER_OTLP_TRACES_HEADERS = "OTEL_EXPORTER_OTLP_TRACES_HEADERS";
5
+ export const OTEL_EXPORTER_OTLP_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS";
6
+ export const OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME";
7
+ export const OTEL_RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES";
8
+ const OTLP_HTTP_PROTOCOLS = new Set(["http:", "https:"]);
9
+ const INVALID_ENDPOINT_URL = "is not an absolute http(s) URL";
10
+ const INVALID_ENDPOINT_CREDENTIALS = `embeds credentials in the URL; send them through ${OTEL_EXPORTER_OTLP_HEADERS} instead`;
11
+ const INVALID_HEADERS = "contains an HTTP header name or value that cannot be sent";
12
+ const EXPLICIT_ENDPOINT_SOURCE = "the configured OTLP endpoint";
13
+ const EXPLICIT_HEADERS_SOURCE = "the configured OTLP headers";
14
+ /** Only an unset or empty variable is absent (OTel env rules); whitespace is a value and is validated as one. */
15
+ function presentValue(value) {
16
+ return value === undefined || value === "" ? undefined : value;
17
+ }
18
+ function parseHttpUrl(value) {
19
+ let url;
20
+ try {
21
+ url = new URL(value);
22
+ }
23
+ catch {
24
+ return { reason: INVALID_ENDPOINT_URL };
25
+ }
26
+ if (!OTLP_HTTP_PROTOCOLS.has(url.protocol))
27
+ return { reason: INVALID_ENDPOINT_URL };
28
+ if (url.username || url.password)
29
+ return { reason: INVALID_ENDPOINT_CREDENTIALS };
30
+ return { url };
31
+ }
32
+ /** fetch() rejects malformed header names/values per request; catch that once at resolve time instead. */
33
+ function headersAreSendable(headers) {
34
+ try {
35
+ new Headers(headers);
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /** Appends the traces signal path to a base URL, keeping every configured path byte and adding only the separator. */
43
+ function appendTracesPath(base) {
44
+ const endpoint = new URL(base.toString());
45
+ const separator = endpoint.pathname.endsWith("/") ? "" : "/";
46
+ endpoint.pathname = `${endpoint.pathname}${separator}v1/traces`;
47
+ return endpoint.toString();
48
+ }
49
+ /** HTTP header names are case-insensitive: a later source replaces an earlier one whatever its casing. */
50
+ function mergeHeaders(...sources) {
51
+ const merged = new Map();
52
+ for (const source of sources) {
53
+ for (const [key, value] of Object.entries(source ?? {})) {
54
+ merged.set(key.toLowerCase(), [key, value]);
55
+ }
56
+ }
57
+ return Object.fromEntries(merged.values());
58
+ }
59
+ function decodeHeaderMember(value) {
60
+ try {
61
+ return decodeURIComponent(value);
62
+ }
63
+ catch {
64
+ // A literal "%" that is not a valid escape is passed through rather than dropping the header.
65
+ return value;
66
+ }
67
+ }
68
+ /** Baggage-style header list: malformed members are skipped and an invalid percent-escape keeps the raw text. */
69
+ function parseHeaderList(value) {
70
+ const entries = [];
71
+ for (const member of value?.split(",") ?? []) {
72
+ const separator = member.indexOf("=");
73
+ if (separator <= 0)
74
+ continue;
75
+ const key = decodeHeaderMember(member.slice(0, separator).trim());
76
+ if (!key)
77
+ continue;
78
+ entries.push([key, decodeHeaderMember(member.slice(separator + 1).trim())]);
79
+ }
80
+ return mergeHeaders(Object.fromEntries(entries));
81
+ }
82
+ /**
83
+ * OTel resource list: a member without `key=value` or with an invalid
84
+ * percent-escape discards the whole value, as the Resource SDK spec requires,
85
+ * so a partially malformed variable can never export a wrong identity.
86
+ */
87
+ function parseResourceAttributeList(value) {
88
+ const entries = [];
89
+ for (const member of value.split(",")) {
90
+ // Every member must be `key=value`; an empty member (`a=b,,c=d`, a trailing comma, a
91
+ // whitespace-only value) is a parse error and discards the whole variable.
92
+ const separator = member.indexOf("=");
93
+ if (separator <= 0)
94
+ return undefined;
95
+ try {
96
+ const key = decodeURIComponent(member.slice(0, separator).trim());
97
+ if (!key)
98
+ return undefined;
99
+ entries.push([key, decodeURIComponent(member.slice(separator + 1).trim())]);
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ return Object.fromEntries(entries);
106
+ }
107
+ /**
108
+ * Resolves the OTLP/HTTP export target from explicit configuration and the
109
+ * standard OpenTelemetry environment contract. Endpoint precedence is explicit
110
+ * config, then OTEL_EXPORTER_OTLP_TRACES_ENDPOINT verbatim, then
111
+ * OTEL_EXPORTER_OTLP_ENDPOINT with `/v1/traces` appended. The winning candidate
112
+ * must be an absolute http(s) URL; an invalid one fails closed instead of
113
+ * falling through to a lower-precedence destination. Header values are never
114
+ * surfaced in the result beyond the request options themselves.
115
+ */
116
+ export function resolveOTLPExportOptions(explicit, env = process.env) {
117
+ const candidates = [
118
+ { source: EXPLICIT_ENDPOINT_SOURCE, value: explicit.endpoint, appendPath: false },
119
+ {
120
+ source: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
121
+ value: env[OTEL_EXPORTER_OTLP_TRACES_ENDPOINT],
122
+ appendPath: false,
123
+ },
124
+ {
125
+ source: OTEL_EXPORTER_OTLP_ENDPOINT,
126
+ value: env[OTEL_EXPORTER_OTLP_ENDPOINT],
127
+ appendPath: true,
128
+ },
129
+ ];
130
+ const candidate = candidates
131
+ .map((entry) => ({ ...entry, value: presentValue(entry.value) }))
132
+ .find((entry) => entry.value !== undefined);
133
+ if (!candidate)
134
+ return { status: "unconfigured" };
135
+ const parsed = parseHttpUrl(candidate.value);
136
+ if ("reason" in parsed) {
137
+ return { status: "invalid", source: candidate.source, reason: parsed.reason };
138
+ }
139
+ const endpoint = candidate.appendPath ? appendTracesPath(parsed.url) : candidate.value;
140
+ const tracesHeaders = presentValue(env[OTEL_EXPORTER_OTLP_TRACES_HEADERS]);
141
+ const headersSource = tracesHeaders !== undefined ? OTEL_EXPORTER_OTLP_TRACES_HEADERS : OTEL_EXPORTER_OTLP_HEADERS;
142
+ const envHeaders = parseHeaderList(tracesHeaders ?? presentValue(env[OTEL_EXPORTER_OTLP_HEADERS]));
143
+ if (!headersAreSendable(envHeaders)) {
144
+ return { status: "invalid", source: headersSource, reason: INVALID_HEADERS };
145
+ }
146
+ const headers = mergeHeaders(envHeaders, explicit.headers);
147
+ if (!headersAreSendable(headers)) {
148
+ return { status: "invalid", source: EXPLICIT_HEADERS_SOURCE, reason: INVALID_HEADERS };
149
+ }
150
+ return {
151
+ status: "resolved",
152
+ options: {
153
+ endpoint,
154
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
155
+ ...(explicit.timeout !== undefined ? { timeout: explicit.timeout } : {}),
156
+ },
157
+ };
158
+ }
159
+ /**
160
+ * Merges OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME under the caller's
161
+ * explicit resource attributes: explicit wins per key, OTEL_SERVICE_NAME wins
162
+ * over a `service.name` inside OTEL_RESOURCE_ATTRIBUTES. An unparseable
163
+ * OTEL_RESOURCE_ATTRIBUTES value is discarded as a unit and reported in `discarded`.
164
+ */
165
+ export function resolveOTLPResourceAttributes(explicit, env = process.env) {
166
+ const discarded = [];
167
+ const resourceList = presentValue(env[OTEL_RESOURCE_ATTRIBUTES]);
168
+ const resourceAttributes = resourceList === undefined ? {} : parseResourceAttributeList(resourceList);
169
+ if (resourceAttributes === undefined)
170
+ discarded.push(OTEL_RESOURCE_ATTRIBUTES);
171
+ const serviceName = presentValue(env[OTEL_SERVICE_NAME]);
172
+ return {
173
+ attributes: {
174
+ ...resourceAttributes,
175
+ ...(serviceName ? { "service.name": serviceName } : {}),
176
+ ...explicit,
177
+ },
178
+ discarded,
179
+ };
180
+ }
1
181
  let nextTraceId = 1n;
2
182
  let replayableTraceId = null;
3
183
  function createBatchSignature(spans, resourceAttributes) {
@@ -36,8 +216,8 @@ function toAttributeValue(value) {
36
216
  }
37
217
  return { stringValue: String(value) };
38
218
  }
39
- export function spansToOTLP(spans, resourceAttributes) {
40
- const traceId = createTraceId(createBatchSignature(spans, resourceAttributes));
219
+ export function spansToOTLP(spans, resourceAttributes, traceId) {
220
+ const batchTraceId = traceId ?? createTraceId(createBatchSignature(spans, resourceAttributes));
41
221
  return {
42
222
  resourceSpans: [
43
223
  {
@@ -54,7 +234,7 @@ export function spansToOTLP(spans, resourceAttributes) {
54
234
  version: "0.1.0",
55
235
  },
56
236
  spans: spans.map((span) => ({
57
- traceId,
237
+ traceId: batchTraceId,
58
238
  spanId: normalizeHexId(span.id, 16) ?? "0000000000000001",
59
239
  parentSpanId: normalizeHexId(span.parentId, 16),
60
240
  name: span.name,
@@ -73,31 +253,232 @@ export function spansToOTLP(spans, resourceAttributes) {
73
253
  ],
74
254
  };
75
255
  }
76
- export async function exportSpansOTLP(spans, options, resourceAttributes) {
77
- if (spans.length === 0) {
256
+ /**
257
+ * Binds the transport that carries collector credentials.
258
+ *
259
+ * Trust model: in production the provider's own entry file is the process entry, so
260
+ * provider-controlled code (its earlier imports, a `bun --preload`, or a bunfig preload) can run
261
+ * before any SDK module evaluates; capture timing alone therefore cannot establish trust. On Bun
262
+ * the native fetch is also exposed as `Bun.fetch`, and both the `Bun` global and its `fetch`
263
+ * property are read-only and non-configurable, so no JavaScript in the process can replace that
264
+ * reference at any point. The engine binds it here and never consults `globalThis.fetch`.
265
+ *
266
+ * Residual assumptions: on a runtime without `Bun.fetch` the fallback is `globalThis.fetch` as
267
+ * seen when this module first evaluates, which code that runs earlier in the same process can
268
+ * have replaced (or can have forged a `Bun` global). Code with process authority can also patch
269
+ * other builtins on this path (Map, Object.fromEntries, Headers, AbortController, setTimeout),
270
+ * install module loader plugins, or reach the internal test seam below by importing this module
271
+ * by path; an in-process boundary cannot defend against any of that. The CLI flows load provider
272
+ * modules only after the engine has loaded.
273
+ */
274
+ function bindEngineTransport() {
275
+ const bunFetch = typeof Bun !== "undefined" ? Bun.fetch : undefined;
276
+ return typeof bunFetch === "function" ? bunFetch : globalThis.fetch;
277
+ }
278
+ const engineTransport = bindEngineTransport();
279
+ let transport = engineTransport;
280
+ // Deliveries run inside this engine-owned async scope, created while the module evaluates, so a
281
+ // batch admitted from another batch's completion never inherits that request's async context.
282
+ // (The scope snapshots the async context active at module load: empty under the static imports
283
+ // the engine uses.)
284
+ const exportScope = new AsyncResource("apifuse.otlp.export");
285
+ /** Process-wide bounds so a collector outage can never turn into unbounded sockets, memory, or log volume. */
286
+ export const OTLP_EXPORT_LIMITS = {
287
+ maxInFlight: 4,
288
+ maxQueued: 64,
289
+ maxAttempts: 3,
290
+ retryBaseDelayMs: 200,
291
+ retryMaxDelayMs: 2_000,
292
+ warningCooldownMs: 10_000,
293
+ };
294
+ const RETRYABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
295
+ const TIMEOUT_ERROR_NAMES = new Set(["AbortError", "TimeoutError"]);
296
+ /** Certificate failures do not clear up on retry; they are reported once and the batch dropped. */
297
+ const CERTIFICATE_ERROR_CODES = new Set([
298
+ "CERT_HAS_EXPIRED",
299
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
300
+ "SELF_SIGNED_CERT_IN_CHAIN",
301
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
302
+ "ERR_TLS_CERT_ALTNAME_INVALID",
303
+ ]);
304
+ /** Only these system codes are ever echoed; anything else is reported as a plain network error. */
305
+ const NETWORK_ERROR_CODES = new Set([
306
+ "ECONNREFUSED",
307
+ "ECONNRESET",
308
+ "ECONNABORTED",
309
+ "ENOTFOUND",
310
+ "EAI_AGAIN",
311
+ "ETIMEDOUT",
312
+ "EHOSTUNREACH",
313
+ "ENETUNREACH",
314
+ "EPIPE",
315
+ "CERT_HAS_EXPIRED",
316
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
317
+ "SELF_SIGNED_CERT_IN_CHAIN",
318
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
319
+ "ERR_TLS_CERT_ALTNAME_INVALID",
320
+ "ConnectionRefused",
321
+ "ConnectionClosed",
322
+ "FailedToOpenSocket",
323
+ ]);
324
+ const queue = [];
325
+ let inFlight = 0;
326
+ let lastWarningAt = Number.NEGATIVE_INFINITY;
327
+ let suppressedDrops = 0;
328
+ let suppressedFlush;
329
+ function errorCode(error) {
330
+ const own = error?.code;
331
+ if (typeof own === "string")
332
+ return own;
333
+ const cause = error?.cause?.code;
334
+ return typeof cause === "string" ? cause : undefined;
335
+ }
336
+ /** Maps a transport failure onto a fixed vocabulary; nothing from the error object is interpolated. */
337
+ function describeExportFailure(error) {
338
+ if (error instanceof Error && TIMEOUT_ERROR_NAMES.has(error.name)) {
339
+ return { reason: "timeout", retryable: true };
340
+ }
341
+ const code = errorCode(error);
342
+ if (code !== undefined && CERTIFICATE_ERROR_CODES.has(code)) {
343
+ return { reason: `certificate error: ${code}`, retryable: false };
344
+ }
345
+ return {
346
+ reason: code !== undefined && NETWORK_ERROR_CODES.has(code)
347
+ ? `network error: ${code}`
348
+ : "network error",
349
+ retryable: true,
350
+ };
351
+ }
352
+ function batchesLabel(count) {
353
+ return `${count} ${count === 1 ? "batch" : "batches"}`;
354
+ }
355
+ /** Emits the count of drops suppressed during a cooldown once it ends, so a burst is never under-reported. */
356
+ function flushSuppressedDrops() {
357
+ suppressedFlush = undefined;
358
+ if (suppressedDrops === 0)
359
+ return;
360
+ const dropped = suppressedDrops;
361
+ suppressedDrops = 0;
362
+ lastWarningAt = Date.now();
363
+ console.warn(`[apifuse] OTLP export: ${batchesLabel(dropped)} more dropped since the last warning.`);
364
+ }
365
+ function noteDroppedBatch(reason) {
366
+ const now = Date.now();
367
+ const sinceLastWarning = now - lastWarningAt;
368
+ if (sinceLastWarning < OTLP_EXPORT_LIMITS.warningCooldownMs) {
369
+ suppressedDrops += 1;
370
+ if (suppressedFlush === undefined) {
371
+ suppressedFlush = setTimeout(flushSuppressedDrops, OTLP_EXPORT_LIMITS.warningCooldownMs - sinceLastWarning);
372
+ suppressedFlush.unref?.();
373
+ }
78
374
  return;
79
375
  }
376
+ lastWarningAt = now;
377
+ console.warn(`[apifuse] OTLP export failed (${reason}); ${batchesLabel(1)} dropped.`);
378
+ }
379
+ async function sendBatch(batch) {
80
380
  const controller = new AbortController();
81
- const timer = setTimeout(() => controller.abort(), options.timeout ?? 5_000);
381
+ const timer = setTimeout(() => controller.abort(), batch.options.timeout ?? 5_000);
82
382
  try {
83
- const response = await fetch(options.endpoint, {
383
+ const response = await transport(batch.options.endpoint, {
84
384
  method: "POST",
85
- headers: {
86
- "Content-Type": "application/json",
87
- ...options.headers,
88
- },
89
- body: JSON.stringify(spansToOTLP(spans, resourceAttributes)),
385
+ headers: mergeHeaders(batch.options.headers, { "Content-Type": "application/json" }),
386
+ body: batch.body,
90
387
  signal: controller.signal,
91
388
  });
92
- if (!response.ok) {
93
- throw new Error(`HTTP ${response.status}`);
389
+ // The reply body is not needed; cancel it while the abort timer still bounds the socket.
390
+ try {
391
+ await response.body?.cancel();
392
+ }
393
+ catch {
394
+ // A body that cannot be cancelled does not change the outcome of the export.
94
395
  }
396
+ if (response.ok)
397
+ return { ok: true };
398
+ return {
399
+ ok: false,
400
+ reason: `HTTP ${response.status}`,
401
+ retryable: RETRYABLE_STATUSES.has(response.status),
402
+ };
95
403
  }
96
404
  catch (error) {
97
- const message = error instanceof Error ? error.message : String(error);
98
- console.warn("[apifuse] OTLP export failed:", message);
405
+ return { ok: false, ...describeExportFailure(error) };
99
406
  }
100
407
  finally {
101
408
  clearTimeout(timer);
102
409
  }
103
410
  }
411
+ /** Exponential backoff between attempts: base * 2^(attempt-1), capped at the maximum delay. */
412
+ export function retryDelayMs(attempt) {
413
+ return Math.min(OTLP_EXPORT_LIMITS.retryBaseDelayMs * 2 ** (attempt - 1), OTLP_EXPORT_LIMITS.retryMaxDelayMs);
414
+ }
415
+ async function deliverBatch(batch) {
416
+ for (let attempt = 1;; attempt += 1) {
417
+ const outcome = await sendBatch(batch);
418
+ if (outcome.ok)
419
+ return;
420
+ if (!outcome.retryable || attempt >= OTLP_EXPORT_LIMITS.maxAttempts) {
421
+ noteDroppedBatch(attempt > 1 ? `${outcome.reason} after ${attempt} attempts` : outcome.reason);
422
+ return;
423
+ }
424
+ await new Promise((resolve) => {
425
+ setTimeout(resolve, retryDelayMs(attempt)).unref?.();
426
+ });
427
+ }
428
+ }
429
+ function pumpQueue() {
430
+ while (inFlight < OTLP_EXPORT_LIMITS.maxInFlight && queue.length > 0) {
431
+ const batch = queue.shift();
432
+ if (!batch)
433
+ return;
434
+ inFlight += 1;
435
+ void exportScope
436
+ .runInAsyncScope(() => deliverBatch(batch))
437
+ .finally(() => {
438
+ inFlight -= 1;
439
+ batch.settle();
440
+ pumpQueue();
441
+ });
442
+ }
443
+ }
444
+ /**
445
+ * Queues one export batch behind the process-wide concurrency and queue bounds.
446
+ * Resolves once the batch has been delivered or dropped; it never rejects, so
447
+ * callers can fire and forget.
448
+ */
449
+ export function exportSpansOTLP(spans, options, resourceAttributes, traceId) {
450
+ if (spans.length === 0) {
451
+ return Promise.resolve();
452
+ }
453
+ if (queue.length >= OTLP_EXPORT_LIMITS.maxQueued) {
454
+ noteDroppedBatch("export queue is full");
455
+ return Promise.resolve();
456
+ }
457
+ let body;
458
+ try {
459
+ body = JSON.stringify(spansToOTLP(spans, resourceAttributes, traceId));
460
+ }
461
+ catch {
462
+ noteDroppedBatch("span serialization failed");
463
+ return Promise.resolve();
464
+ }
465
+ return new Promise((settle) => {
466
+ queue.push({ body, options, settle });
467
+ pumpQueue();
468
+ });
469
+ }
470
+ /** Test seam (not re-exported from any package entry point): substitute the engine transport. */
471
+ export function swapOTLPTransportForTests(next) {
472
+ transport = next ?? engineTransport;
473
+ }
474
+ /** Test seam: drop queued batches, clear the warning throttle, and restore the engine transport. */
475
+ export function resetOTLPExportForTests() {
476
+ for (const batch of queue.splice(0))
477
+ batch.settle();
478
+ if (suppressedFlush !== undefined)
479
+ clearTimeout(suppressedFlush);
480
+ suppressedFlush = undefined;
481
+ lastWarningAt = Number.NEGATIVE_INFINITY;
482
+ suppressedDrops = 0;
483
+ transport = engineTransport;
484
+ }
@@ -1 +1 @@
1
- export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, bindResolverSignal, createResolverClient, createResolverClientFromEnv, createUnsupportedResolverClient, DEFAULT_RESOLVER_VENDOR_PREFERENCE, DEFAULT_RESOLVER_TIMEOUT_MS, invalidateResolverSolution, RESOLVER_ADAPTER_REGISTRY, RESOLVER_INSTRUMENTATION_METADATA, resolveProviderResolverVendors, type ResolverAdapterFactory, type ResolverInstrumentationMetadata, type ResolverRuntimeOptions, } from "./resolver.js";
1
+ export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, bindResolverSignal, createResolverClient, createResolverClientFromEnv, createUnsupportedResolverClient, DEFAULT_RESOLVER_VENDOR_PREFERENCE, DEFAULT_RESOLVER_TIMEOUT_MS, getResolverSolutionSource, invalidateCachedResolverSolution, invalidateResolverSolution, RESOLVER_ADAPTER_REGISTRY, RESOLVER_INSTRUMENTATION_METADATA, resolveProviderResolverVendors, type ResolverAdapterFactory, type ResolverInstrumentationMetadata, type ResolverRuntimeOptions, type ResolverSolutionSource, } from "./resolver.js";
@@ -1 +1 @@
1
- export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, bindResolverSignal, createResolverClient, createResolverClientFromEnv, createUnsupportedResolverClient, DEFAULT_RESOLVER_VENDOR_PREFERENCE, DEFAULT_RESOLVER_TIMEOUT_MS, invalidateResolverSolution, RESOLVER_ADAPTER_REGISTRY, RESOLVER_INSTRUMENTATION_METADATA, resolveProviderResolverVendors, } from "./resolver.js";
1
+ export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, bindResolverSignal, createResolverClient, createResolverClientFromEnv, createUnsupportedResolverClient, DEFAULT_RESOLVER_VENDOR_PREFERENCE, DEFAULT_RESOLVER_TIMEOUT_MS, getResolverSolutionSource, invalidateCachedResolverSolution, invalidateResolverSolution, RESOLVER_ADAPTER_REGISTRY, RESOLVER_INSTRUMENTATION_METADATA, resolveProviderResolverVendors, } from "./resolver.js";
@@ -1,12 +1,17 @@
1
1
  import { getStealthProfile } from "../../stealth/profiles.js";
2
2
  import { redactSensitiveText } from "../request-options.js";
3
- import { DEFAULT_PROFILE } from "../stealth.js";
3
+ import { DEFAULT_STEALTH_PROFILE } from "../stealth.js";
4
4
  import { assertResolverHostAllowed } from "./hosts.js";
5
5
  import { ResolverChallengeVerdictError, ResolverVendorUnavailableError, resolverVendorSupports, } from "./types.js";
6
6
  const CAPSOLVER_VENDOR_ID = "capsolver";
7
7
  const DEFAULT_CAPSOLVER_BASE_URL = "https://api.capsolver.com";
8
8
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
9
9
  const DEFAULT_TIMEOUT_MS = 120_000;
10
+ const SDK_ESTIMATED_COOKIE_TTL_MS_BY_CHALLENGE_KIND = {
11
+ // CapSolver omits expiry, so use one conservative hour despite measured
12
+ // AWS WAF lifetimes of days.
13
+ aws_waf: 60 * 60 * 1_000,
14
+ };
10
15
  class CapsolverSolveTimeoutError extends Error {
11
16
  constructor() {
12
17
  super("Capsolver resolver solve budget elapsed");
@@ -470,7 +475,7 @@ export function createCapsolverResolverVendorAdapter(options) {
470
475
  cookies: cookies && Object.keys(cookies).length > 0 ? cookies : { cf_clearance: clearance },
471
476
  userAgent: result.payload.solution?.userAgent ??
472
477
  identity?.userAgent ??
473
- getStealthProfile(DEFAULT_PROFILE).userAgent,
478
+ getStealthProfile(DEFAULT_STEALTH_PROFILE).userAgent,
474
479
  };
475
480
  }
476
481
  if (!solutionValue?.trim()) {
@@ -482,7 +487,8 @@ export function createCapsolverResolverVendorAdapter(options) {
482
487
  ? {
483
488
  form: "cookies",
484
489
  cookies: { "aws-waf-token": solutionValue },
485
- userAgent: identity?.userAgent ?? getStealthProfile(DEFAULT_PROFILE).userAgent,
490
+ userAgent: identity?.userAgent ?? getStealthProfile(DEFAULT_STEALTH_PROFILE).userAgent,
491
+ sdkEstimatedExpires: (now() + SDK_ESTIMATED_COOKIE_TTL_MS_BY_CHALLENGE_KIND.aws_waf) / 1_000,
486
492
  }
487
493
  : { form: "token", token: solutionValue };
488
494
  }
@@ -365,6 +365,7 @@ export function createTwoCaptchaResolverVendorAdapter(options) {
365
365
  if (!token?.trim()) {
366
366
  throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", { phase });
367
367
  }
368
+ // AWS WAF remains a token solution here, so resolver cookie caching does not apply.
368
369
  return { form: "token", token };
369
370
  }
370
371
  };
@@ -31,6 +31,7 @@ export interface ResolverRuntimeOptions {
31
31
  readonly identityScope?: string;
32
32
  }) => ResolverVendorTransport;
33
33
  }
34
+ export type ResolverSolutionSource = "cache" | "vendor";
34
35
  export type ResolverInstrumentationMetadata = {
35
36
  readonly target: ResolverContext;
36
37
  readonly traceRecorder: TraceRecorder;
@@ -40,8 +41,19 @@ export declare const RESOLVER_ADAPTER_REGISTRY: Partial<Readonly<Record<Provider
40
41
  export declare function swapResolverAdapterFactoryForTests(vendor: ProviderResolverVendor, factory: ResolverAdapterFactory | undefined): () => void;
41
42
  /** Internal test seam; deliberately not re-exported from the package root. */
42
43
  export declare function swapResolverDefaultUserAgentForTests(resolver: (() => string | undefined) | undefined): () => void;
44
+ /**
45
+ * Identify whether this exact SDK-returned solution object came from the cache
46
+ * or a vendor solve. Returns undefined for copied or caller-created objects.
47
+ */
48
+ export declare function getResolverSolutionSource(solution: ChallengeSolution): ResolverSolutionSource | undefined;
43
49
  /** Remove the cached entry for the exact solution object returned by this resolver. */
44
50
  export declare function invalidateResolverSolution(resolver: ResolverContext, challenge: ProviderChallenge, solution: ChallengeSolution): Promise<void>;
51
+ /**
52
+ * Remove a solution only when it was returned from the resolver cache. Providers
53
+ * should call this when an upstream serves the same challenge after a cached
54
+ * solution was applied, so the next `solve()` mints a fresh solution.
55
+ */
56
+ export declare function invalidateCachedResolverSolution(resolver: ResolverContext, challenge: ProviderChallenge, solution: ChallengeSolution): Promise<boolean>;
45
57
  export declare function createResolverClient(options: {
46
58
  readonly kinds: readonly ProviderChallengeKind[];
47
59
  readonly adapters: readonly ResolverVendorAdapter[];
@@ -10,7 +10,7 @@ import { createTwoCaptchaResolverVendorAdapter } from "./resolver-vendors/twocap
10
10
  import { RESOLVER_VENDOR_CAPABILITIES, ResolverVendorUnavailableError, resolveProviderResolverVendors, resolverVendorSupports, } from "./resolver-vendors/types.js";
11
11
  import { createUnsupportedResolverClient, RESOLVER_INSTRUMENTATION_METADATA, } from "./resolver-shared.js";
12
12
  import { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, DEFAULT_RESOLVER_TIMEOUT_MS, } from "./resolver-config.js";
13
- import { DEFAULT_PROFILE } from "./stealth.js";
13
+ import { DEFAULT_STEALTH_PROFILE } from "./stealth.js";
14
14
  export { createUnsupportedResolverClient, RESOLVER_INSTRUMENTATION_METADATA, } from "./resolver-shared.js";
15
15
  export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, DEFAULT_RESOLVER_TIMEOUT_MS, } from "./resolver-config.js";
16
16
  export { DEFAULT_RESOLVER_VENDOR_PREFERENCE, resolveProviderResolverVendors, } from "./resolver-vendors/types.js";
@@ -19,6 +19,7 @@ const RESOLVER_SOLUTION_INDEX_CACHE_NAMESPACE = "resolver-solution-index";
19
19
  const MIN_RESOLVER_CACHE_TTL_MS = 1_000;
20
20
  const resolverCaches = new WeakMap();
21
21
  const solutionIssuerDigests = new WeakMap();
22
+ const resolverSolutionSources = new WeakMap();
22
23
  const SAFE_CAUSE_MESSAGE_WORDS = new Set([
23
24
  "abort",
24
25
  "aborted",
@@ -99,12 +100,12 @@ export function swapResolverAdapterFactoryForTests(vendor, factory) {
99
100
  resolverAdapterRegistry[vendor] = original;
100
101
  };
101
102
  }
102
- let resolveDefaultResolverUserAgent = () => getStealthProfile(DEFAULT_PROFILE).userAgent;
103
+ let resolveDefaultResolverUserAgent = () => getStealthProfile(DEFAULT_STEALTH_PROFILE).userAgent;
103
104
  /** Internal test seam; deliberately not re-exported from the package root. */
104
105
  export function swapResolverDefaultUserAgentForTests(resolver) {
105
106
  const original = resolveDefaultResolverUserAgent;
106
107
  resolveDefaultResolverUserAgent =
107
- resolver ?? (() => getStealthProfile(DEFAULT_PROFILE).userAgent);
108
+ resolver ?? (() => getStealthProfile(DEFAULT_STEALTH_PROFILE).userAgent);
108
109
  let restored = false;
109
110
  return () => {
110
111
  if (restored)
@@ -376,7 +377,7 @@ function isResolverCacheIndex(value) {
376
377
  function solutionExpiryMs(solution) {
377
378
  if (solution.form !== "cookies")
378
379
  return undefined;
379
- const expires = solution.expires;
380
+ const expires = solution.expires ?? solution.sdkEstimatedExpires;
380
381
  if (typeof expires !== "number" || !Number.isFinite(expires))
381
382
  return undefined;
382
383
  return expires * 1_000;
@@ -386,14 +387,33 @@ function rememberSolutionIssuer(solution, issuerDigest) {
386
387
  solutionIssuerDigests.set(solution, issuerDigest);
387
388
  }
388
389
  }
390
+ function rememberSolutionSource(solution, source) {
391
+ if (typeof solution === "object" && solution !== null) {
392
+ resolverSolutionSources.set(solution, source);
393
+ }
394
+ }
395
+ /**
396
+ * Identify whether this exact SDK-returned solution object came from the cache
397
+ * or a vendor solve. Returns undefined for copied or caller-created objects.
398
+ */
399
+ export function getResolverSolutionSource(solution) {
400
+ return resolverSolutionSources.get(solution);
401
+ }
389
402
  async function readCachedSolution(cache, challenge, issuerDigest, now) {
390
403
  const cached = await cache.get(resolverSolutionCacheKey(cache, challenge, issuerDigest));
391
404
  if (!cached || !isCachedResolverSolution(cached.value))
392
405
  return undefined;
393
406
  if (cached.value.issuerDigest !== issuerDigest || cached.value.expiresAtMs <= now)
394
407
  return undefined;
395
- rememberSolutionIssuer(cached.value.solution, issuerDigest);
396
- return cached.value.solution;
408
+ if (cached.value.solution.form !== "cookies")
409
+ return undefined;
410
+ const solution = {
411
+ ...cached.value.solution,
412
+ cookies: { ...cached.value.solution.cookies },
413
+ };
414
+ rememberSolutionIssuer(solution, issuerDigest);
415
+ rememberSolutionSource(solution, "cache");
416
+ return solution;
397
417
  }
398
418
  async function findCachedSolution(cache, challenge, identity, identityScope) {
399
419
  const now = Date.now();
@@ -457,8 +477,7 @@ async function cacheResolverSolution(cache, challenge, solution, identity, ident
457
477
  { direct: true, expiresAtMs, issuerDigest },
458
478
  ], now);
459
479
  }
460
- /** Remove the cached entry for the exact solution object returned by this resolver. */
461
- export async function invalidateResolverSolution(resolver, challenge, solution) {
480
+ async function invalidateResolverSolutionWithOutcome(resolver, challenge, solution) {
462
481
  const metadata = resolver[RESOLVER_INSTRUMENTATION_METADATA];
463
482
  const cacheOwner = metadata?.target ?? resolver;
464
483
  const invalidate = async () => {
@@ -484,14 +503,28 @@ export async function invalidateResolverSolution(resolver, challenge, solution)
484
503
  return "index_entry_deleted";
485
504
  };
486
505
  if (!metadata) {
487
- await invalidate();
488
- return;
506
+ return await invalidate();
489
507
  }
490
- await metadata.traceRecorder.runSpan("resolver.cache.invalidate", invalidate, {
508
+ return await metadata.traceRecorder.runSpan("resolver.cache.invalidate", invalidate, {
491
509
  attributes: { challenge_kind: challenge.kind },
492
510
  onSuccess: (outcome) => ({ outcome }),
493
511
  });
494
512
  }
513
+ /** Remove the cached entry for the exact solution object returned by this resolver. */
514
+ export async function invalidateResolverSolution(resolver, challenge, solution) {
515
+ await invalidateResolverSolutionWithOutcome(resolver, challenge, solution);
516
+ }
517
+ /**
518
+ * Remove a solution only when it was returned from the resolver cache. Providers
519
+ * should call this when an upstream serves the same challenge after a cached
520
+ * solution was applied, so the next `solve()` mints a fresh solution.
521
+ */
522
+ export async function invalidateCachedResolverSolution(resolver, challenge, solution) {
523
+ if (getResolverSolutionSource(solution) !== "cache")
524
+ return false;
525
+ const outcome = await invalidateResolverSolutionWithOutcome(resolver, challenge, solution);
526
+ return outcome === "entry_deleted" || outcome === "index_entry_deleted";
527
+ }
495
528
  async function resolveResolverIdentity(proxyIntent) {
496
529
  const userAgentSource = proxyIntent.userAgent ? "declared" : "defaulted";
497
530
  let proxyUrl;
@@ -618,6 +651,7 @@ function createResolverChainClient(options) {
618
651
  await cacheResolverSolution(options.cache, challenge, solution, issuingIdentity, options.identityScope);
619
652
  }
620
653
  }
654
+ rememberSolutionSource(solution, "vendor");
621
655
  return solution;
622
656
  }
623
657
  catch (error) {