@prosopo/provider 5.11.1 → 5.12.2

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 (115) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +116 -115
  2. package/.turbo/turbo-build$colon$tsc.log +27 -27
  3. package/.turbo/turbo-build.log +117 -116
  4. package/CHANGELOG.md +116 -0
  5. package/dist/api/blacklistRequestInspector.d.ts +5 -2
  6. package/dist/api/blacklistRequestInspector.d.ts.map +1 -1
  7. package/dist/api/blacklistRequestInspector.js +10 -5
  8. package/dist/api/blacklistRequestInspector.js.map +1 -1
  9. package/dist/api/captcha/getFrictionlessCaptchaChallenge/handler.js +1 -1
  10. package/dist/api/captcha/getPoWCaptchaChallenge.js +1 -1
  11. package/dist/api/captcha/getPuzzleCaptchaChallenge.js +1 -1
  12. package/dist/api/cpuProfiler.d.ts +32 -0
  13. package/dist/api/cpuProfiler.d.ts.map +1 -0
  14. package/dist/api/cpuProfiler.js +128 -0
  15. package/dist/api/cpuProfiler.js.map +1 -0
  16. package/dist/api/metrics.d.ts +9 -0
  17. package/dist/api/metrics.d.ts.map +1 -1
  18. package/dist/api/metrics.js +107 -1
  19. package/dist/api/metrics.js.map +1 -1
  20. package/dist/api/startProviderApi.d.ts.map +1 -1
  21. package/dist/api/startProviderApi.js +2 -0
  22. package/dist/api/startProviderApi.js.map +1 -1
  23. package/dist/cjs/api/blacklistRequestInspector.cjs +10 -5
  24. package/dist/cjs/api/captcha/getFrictionlessCaptchaChallenge/handler.cjs +1 -1
  25. package/dist/cjs/api/captcha/getPoWCaptchaChallenge.cjs +1 -1
  26. package/dist/cjs/api/captcha/getPuzzleCaptchaChallenge.cjs +1 -1
  27. package/dist/cjs/api/cpuProfiler.cjs +129 -0
  28. package/dist/cjs/api/metrics.cjs +108 -0
  29. package/dist/cjs/api/startProviderApi.cjs +2 -0
  30. package/dist/cjs/tasks/captchaManager.cjs +61 -4
  31. package/dist/cjs/tasks/decisionMachine/decisionMachineRunner.cjs +2 -1
  32. package/dist/cjs/tasks/detection/decoderModules.cjs +14 -0
  33. package/dist/cjs/tasks/detection/decoderPool.cjs +183 -0
  34. package/dist/cjs/tasks/detection/getBotScore.cjs +9 -2
  35. package/dist/cjs/tasks/imgCaptcha/imgCaptchaTasks.cjs +6 -3
  36. package/dist/cjs/tasks/imgCaptcha/imgCaptchaTasksUtils.cjs +5 -2
  37. package/dist/cjs/tasks/powCaptcha/powTasks.cjs +6 -3
  38. package/dist/cjs/tasks/powCaptcha/powTasksUtils.cjs +2 -2
  39. package/dist/cjs/tasks/puzzle/backgroundBuffer.cjs +3 -2
  40. package/dist/cjs/tasks/puzzle/nativeBackground.cjs +30 -0
  41. package/dist/cjs/tasks/puzzle/puzzleRenderer.cjs +3 -2
  42. package/dist/cjs/tasks/puzzleCaptcha/puzzleTasks.cjs +6 -3
  43. package/dist/tasks/captchaManager.d.ts +7 -0
  44. package/dist/tasks/captchaManager.d.ts.map +1 -1
  45. package/dist/tasks/captchaManager.js +61 -4
  46. package/dist/tasks/captchaManager.js.map +1 -1
  47. package/dist/tasks/decisionMachine/decisionMachineRunner.d.ts.map +1 -1
  48. package/dist/tasks/decisionMachine/decisionMachineRunner.js +2 -1
  49. package/dist/tasks/decisionMachine/decisionMachineRunner.js.map +1 -1
  50. package/dist/tasks/detection/decoderModules.d.ts +5 -0
  51. package/dist/tasks/detection/decoderModules.d.ts.map +1 -0
  52. package/dist/tasks/detection/decoderModules.js +13 -0
  53. package/dist/tasks/detection/decoderModules.js.map +1 -0
  54. package/dist/tasks/detection/decoderPool.d.ts +4 -0
  55. package/dist/tasks/detection/decoderPool.d.ts.map +1 -0
  56. package/dist/tasks/detection/decoderPool.js +183 -0
  57. package/dist/tasks/detection/decoderPool.js.map +1 -0
  58. package/dist/tasks/detection/getBotScore.js +9 -2
  59. package/dist/tasks/detection/getBotScore.js.map +1 -1
  60. package/dist/tasks/imgCaptcha/imgCaptchaTasks.d.ts.map +1 -1
  61. package/dist/tasks/imgCaptcha/imgCaptchaTasks.js +6 -3
  62. package/dist/tasks/imgCaptcha/imgCaptchaTasks.js.map +1 -1
  63. package/dist/tasks/imgCaptcha/imgCaptchaTasksUtils.d.ts.map +1 -1
  64. package/dist/tasks/imgCaptcha/imgCaptchaTasksUtils.js +5 -2
  65. package/dist/tasks/imgCaptcha/imgCaptchaTasksUtils.js.map +1 -1
  66. package/dist/tasks/powCaptcha/powTasks.d.ts.map +1 -1
  67. package/dist/tasks/powCaptcha/powTasks.js +6 -3
  68. package/dist/tasks/powCaptcha/powTasks.js.map +1 -1
  69. package/dist/tasks/powCaptcha/powTasksUtils.js +1 -1
  70. package/dist/tasks/powCaptcha/powTasksUtils.js.map +1 -1
  71. package/dist/tasks/puzzle/backgroundBuffer.d.ts.map +1 -1
  72. package/dist/tasks/puzzle/backgroundBuffer.js +4 -3
  73. package/dist/tasks/puzzle/backgroundBuffer.js.map +1 -1
  74. package/dist/tasks/puzzle/nativeBackground.d.ts +3 -0
  75. package/dist/tasks/puzzle/nativeBackground.d.ts.map +1 -0
  76. package/dist/tasks/puzzle/nativeBackground.js +31 -0
  77. package/dist/tasks/puzzle/nativeBackground.js.map +1 -0
  78. package/dist/tasks/puzzle/puzzleRenderer.d.ts.map +1 -1
  79. package/dist/tasks/puzzle/puzzleRenderer.js +3 -2
  80. package/dist/tasks/puzzle/puzzleRenderer.js.map +1 -1
  81. package/dist/tasks/puzzleCaptcha/puzzleTasks.d.ts.map +1 -1
  82. package/dist/tasks/puzzleCaptcha/puzzleTasks.js +6 -3
  83. package/dist/tasks/puzzleCaptcha/puzzleTasks.js.map +1 -1
  84. package/dist/tests/integration/powCaptcha.integration.test.js +1 -1
  85. package/dist/tests/integration/powCaptcha.integration.test.js.map +1 -1
  86. package/dist/tests/integration/webBotAuthAuthenticated.integration.test.js +2 -2
  87. package/dist/tests/integration/webBotAuthAuthenticated.integration.test.js.map +1 -1
  88. package/dist/tests/unit/api/blacklistRequestInspector.unit.test.js +42 -1
  89. package/dist/tests/unit/api/blacklistRequestInspector.unit.test.js.map +1 -1
  90. package/dist/tests/unit/api/cpuProfiler.unit.test.d.ts +2 -0
  91. package/dist/tests/unit/api/cpuProfiler.unit.test.d.ts.map +1 -0
  92. package/dist/tests/unit/api/cpuProfiler.unit.test.js +47 -0
  93. package/dist/tests/unit/api/cpuProfiler.unit.test.js.map +1 -0
  94. package/dist/tests/unit/api/cpuSpans.unit.test.d.ts +2 -0
  95. package/dist/tests/unit/api/cpuSpans.unit.test.d.ts.map +1 -0
  96. package/dist/tests/unit/api/cpuSpans.unit.test.js +37 -0
  97. package/dist/tests/unit/api/cpuSpans.unit.test.js.map +1 -0
  98. package/dist/tests/unit/tasks/captchaManager.unit.test.js +83 -5
  99. package/dist/tests/unit/tasks/captchaManager.unit.test.js.map +1 -1
  100. package/dist/tests/unit/tasks/detection/decoderPool.unit.test.d.ts +2 -0
  101. package/dist/tests/unit/tasks/detection/decoderPool.unit.test.d.ts.map +1 -0
  102. package/dist/tests/unit/tasks/detection/decoderPool.unit.test.js +59 -0
  103. package/dist/tests/unit/tasks/detection/decoderPool.unit.test.js.map +1 -0
  104. package/dist/tests/unit/tasks/detection/getBotScore.unit.test.js +39 -18
  105. package/dist/tests/unit/tasks/detection/getBotScore.unit.test.js.map +1 -1
  106. package/dist/tests/unit/tasks/powCaptcha/powTasksUtils.unit.test.js +1 -1
  107. package/dist/tests/unit/tasks/powCaptcha/powTasksUtils.unit.test.js.map +1 -1
  108. package/dist/tests/unit/tasks/puzzle/nativeBackground.unit.test.d.ts +2 -0
  109. package/dist/tests/unit/tasks/puzzle/nativeBackground.unit.test.d.ts.map +1 -0
  110. package/dist/tests/unit/tasks/puzzle/nativeBackground.unit.test.js +50 -0
  111. package/dist/tests/unit/tasks/puzzle/nativeBackground.unit.test.js.map +1 -0
  112. package/package.json +25 -24
  113. package/dist/cjs/tasks/detection/decodeBehavior.cjs +0 -721
  114. package/dist/cjs/tasks/detection/decodePayload.cjs +0 -1325
  115. package/dist/cjs/tasks/detection/decodeSimd.cjs +0 -805
@@ -0,0 +1,129 @@
1
+ let node_inspector_promises = require("node:inspector/promises");
2
+ //#region src/api/cpuProfiler.ts
3
+ var enabled = () => process.env.PROSOPO_CPU_PROFILE_ENABLED === "true";
4
+ var numberFromEnv = (name, fallback) => {
5
+ const parsed = Number.parseInt(process.env[name] ?? "", 10);
6
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
7
+ };
8
+ var SYNTHETIC_FRAMES = /* @__PURE__ */ new Set([
9
+ "(idle)",
10
+ "(program)",
11
+ "(root)"
12
+ ]);
13
+ /**
14
+ * Collapse a CDP profile into self time per call frame.
15
+ *
16
+ * `timeDeltas[i]` is the gap between sample `i-1` and sample `i`, so charging
17
+ * each delta to the node named by `samples[i]` gives self time — the time that
18
+ * frame was on top of the stack, not the time spent beneath it.
19
+ */
20
+ var summariseProfile = (profile, topN) => {
21
+ const framesById = new Map(profile.nodes.map((node) => [node.id, node]));
22
+ const samples = profile.samples ?? [];
23
+ const timeDeltas = profile.timeDeltas ?? [];
24
+ const selfMicros = /* @__PURE__ */ new Map();
25
+ let accountedMicros = 0;
26
+ for (let i = 0; i < samples.length; i++) {
27
+ const id = samples[i];
28
+ const delta = timeDeltas[i];
29
+ if (id === void 0 || delta === void 0 || delta < 0) continue;
30
+ selfMicros.set(id, (selfMicros.get(id) ?? 0) + delta);
31
+ accountedMicros += delta;
32
+ }
33
+ let idleMicros = 0;
34
+ const ranked = [];
35
+ for (const [id, micros] of selfMicros) {
36
+ const node = framesById.get(id);
37
+ if (!node) continue;
38
+ const { functionName, url, lineNumber } = node.callFrame;
39
+ if (SYNTHETIC_FRAMES.has(functionName)) {
40
+ idleMicros += micros;
41
+ continue;
42
+ }
43
+ ranked.push({
44
+ fn: functionName || "(anonymous)",
45
+ url,
46
+ line: lineNumber,
47
+ selfMs: micros / 1e3,
48
+ pct: 0
49
+ });
50
+ }
51
+ ranked.sort((a, b) => b.selfMs - a.selfMs);
52
+ const busyMs = Math.max(accountedMicros - idleMicros, 0) / 1e3;
53
+ for (const frame of ranked) frame.pct = busyMs > 0 ? frame.selfMs / busyMs * 100 : 0;
54
+ return {
55
+ durationMs: (profile.endTime - profile.startTime) / 1e3,
56
+ samples: samples.length,
57
+ accountedMs: accountedMicros / 1e3,
58
+ idlePct: accountedMicros > 0 ? idleMicros / accountedMicros * 100 : 0,
59
+ top: ranked.slice(0, topN)
60
+ };
61
+ };
62
+ var runOnce = async (logger) => {
63
+ const durationMs = numberFromEnv("PROSOPO_CPU_PROFILE_DURATION_MS", 5e3);
64
+ const samplingUs = numberFromEnv("PROSOPO_CPU_PROFILE_SAMPLING_US", 1e3);
65
+ const topN = numberFromEnv("PROSOPO_CPU_PROFILE_TOP_N", 25);
66
+ const session = new node_inspector_promises.Session();
67
+ try {
68
+ session.connect();
69
+ await session.post("Profiler.enable");
70
+ await session.post("Profiler.setSamplingInterval", { interval: samplingUs });
71
+ await session.post("Profiler.start");
72
+ await new Promise((resolve) => setTimeout(resolve, durationMs));
73
+ const { profile } = await session.post("Profiler.stop");
74
+ const summary = summariseProfile(profile, topN);
75
+ logger.info(() => ({
76
+ msg: "CPU profile",
77
+ data: {
78
+ windowMs: summary.durationMs,
79
+ samples: summary.samples,
80
+ idlePct: Number(summary.idlePct.toFixed(1)),
81
+ samplingUs,
82
+ top: summary.top.map((frame) => ({
83
+ fn: frame.fn,
84
+ at: `${frame.url}:${frame.line}`,
85
+ selfMs: Number(frame.selfMs.toFixed(2)),
86
+ pct: Number(frame.pct.toFixed(2))
87
+ }))
88
+ }
89
+ }));
90
+ } finally {
91
+ try {
92
+ session.disconnect();
93
+ } catch {}
94
+ }
95
+ };
96
+ /**
97
+ * Sample the isolate for a window, log the ranked self time, repeat.
98
+ *
99
+ * Returns a stop function. Never throws into the caller and never keeps the
100
+ * process alive: a broken profiler must not be able to take the provider with
101
+ * it, and a provider that is otherwise idle must still be able to exit.
102
+ */
103
+ var startCpuProfiler = (logger) => {
104
+ if (!enabled()) return () => void 0;
105
+ const intervalMs = numberFromEnv("PROSOPO_CPU_PROFILE_INTERVAL_MS", 9e5);
106
+ let running = false;
107
+ const tick = () => {
108
+ if (running) return;
109
+ running = true;
110
+ runOnce(logger).catch((err) => {
111
+ logger.warn(() => ({
112
+ msg: "CPU profile failed",
113
+ err
114
+ }));
115
+ }).finally(() => {
116
+ running = false;
117
+ });
118
+ };
119
+ logger.info(() => ({
120
+ msg: "CPU profiler enabled",
121
+ data: { intervalMs }
122
+ }));
123
+ const timer = setInterval(tick, intervalMs);
124
+ timer.unref?.();
125
+ return () => clearInterval(timer);
126
+ };
127
+ //#endregion
128
+ exports.startCpuProfiler = startCpuProfiler;
129
+ exports.summariseProfile = summariseProfile;
@@ -34,6 +34,21 @@ var SCORE_BUCKETS = [
34
34
  5,
35
35
  10
36
36
  ];
37
+ var SPAN_BUCKETS = [
38
+ 1e-4,
39
+ 25e-5,
40
+ 5e-4,
41
+ .001,
42
+ .0025,
43
+ .005,
44
+ .01,
45
+ .025,
46
+ .05,
47
+ .1,
48
+ .25,
49
+ .5,
50
+ 1
51
+ ];
37
52
  var KNOWN_ROUTES = /* @__PURE__ */ new Set([
38
53
  ...Object.values(_prosopo_types.ClientApiPaths),
39
54
  ...Object.values(_prosopo_types.PublicApiPaths),
@@ -160,6 +175,38 @@ var buildMetrics = () => {
160
175
  help: "Healthz geo steering outcomes (steered/not_steered/target_down/geo_unavailable)",
161
176
  labelNames: ["outcome"],
162
177
  registers: [registry]
178
+ }),
179
+ decoderDuration: new prom_client.Histogram({
180
+ name: `${PREFIX}decoder_duration_seconds`,
181
+ help: "End-to-end duration of a detector payload decode, worker round trip included",
182
+ labelNames: ["decoder"],
183
+ buckets: SPAN_BUCKETS,
184
+ registers: [registry]
185
+ }),
186
+ decoderCallsTotal: new prom_client.Counter({
187
+ name: `${PREFIX}decoder_calls_total`,
188
+ help: "Detector payload decodes by decoder and outcome",
189
+ labelNames: ["decoder", "outcome"],
190
+ registers: [registry]
191
+ }),
192
+ syncSpanWallSecondsTotal: new prom_client.Counter({
193
+ name: `${PREFIX}sync_span_wall_seconds_total`,
194
+ help: "Seconds the event loop was held by named blocks of synchronous work",
195
+ labelNames: ["span"],
196
+ registers: [registry]
197
+ }),
198
+ syncSpanCallsTotal: new prom_client.Counter({
199
+ name: `${PREFIX}sync_span_calls_total`,
200
+ help: "Calls into each named block of synchronous work, successful or not",
201
+ labelNames: ["span"],
202
+ registers: [registry]
203
+ }),
204
+ syncSpanDuration: new prom_client.Histogram({
205
+ name: `${PREFIX}sync_span_duration_seconds`,
206
+ help: "Duration of each named block of synchronous work",
207
+ labelNames: ["span"],
208
+ buckets: SPAN_BUCKETS,
209
+ registers: [registry]
163
210
  })
164
211
  };
165
212
  };
@@ -216,6 +263,65 @@ var setMaintenanceModeGauge = (on) => {
216
263
  if (!metricsEnabled()) return;
217
264
  getMetrics().maintenanceMode.set(on ? 1 : 0);
218
265
  };
266
+ /**
267
+ * Time one detector decode end to end, worker round trip included.
268
+ *
269
+ * Wall time here is not event-loop time — the decode runs on a worker, so
270
+ * this is latency the request sees rather than delay it imposes on its
271
+ * neighbours. That is the point: watching it alongside
272
+ * `prosopo_nodejs_eventloop_lag_p99_seconds` is how you tell that the work
273
+ * moved rather than vanished.
274
+ */
275
+ var recordDecoderOutcome = (decoder, outcome, durationSeconds) => {
276
+ if (!metricsEnabled()) return;
277
+ const m = getMetrics();
278
+ m.decoderCallsTotal.inc({
279
+ decoder,
280
+ outcome
281
+ });
282
+ m.decoderDuration.observe({ decoder }, durationSeconds);
283
+ };
284
+ /**
285
+ * Measure how long a named block of synchronous work holds the event loop.
286
+ *
287
+ * `increase(prosopo_sync_span_wall_seconds_total[1d])` by span is the ranking
288
+ * this exists to produce: which blocks are worth moving off the loop, ordered
289
+ * by how much of it they actually consume rather than by how expensive they
290
+ * look.
291
+ *
292
+ * Wall time, not CPU time, and that is not a compromise. Nothing else on this
293
+ * thread can run while `fn` does, so its wall time *is* the delay it imposes
294
+ * on every other request in flight — which is the quantity we care about.
295
+ *
296
+ * An earlier version also recorded a `process.cpuUsage()` delta and claimed it
297
+ * was exact for the same reason. It was not: cpuUsage covers the whole
298
+ * process, so V8's background GC and compiler threads and libuv's threadpool
299
+ * all landed on whichever span happened to be open. In production it read
300
+ * consistently *higher* than wall time, which is impossible for single-thread
301
+ * work and is what gave it away. Node exposes no per-thread CPU clock, so
302
+ * there is no fixed version of that metric to keep — process-wide CPU is
303
+ * already available as `prosopo_process_cpu_seconds_total`.
304
+ *
305
+ * This takes `() => T` and not an async callback for the same reason: wrapping
306
+ * an awaiting function would time every other request served during its awaits.
307
+ * Passing a function that *returns* a promise is fine and intended — several of
308
+ * our decoders are async in signature but do their work synchronously before
309
+ * resolving. Only the synchronous part is timed, so if one of them later grows
310
+ * a real await the span under-reports rather than over-reports.
311
+ */
312
+ var measureSync = (span, fn) => {
313
+ if (!metricsEnabled()) return fn();
314
+ const wallBefore = process.hrtime.bigint();
315
+ try {
316
+ return fn();
317
+ } finally {
318
+ const wallSeconds = Number(process.hrtime.bigint() - wallBefore) / 1e9;
319
+ const m = getMetrics();
320
+ m.syncSpanCallsTotal.inc({ span });
321
+ m.syncSpanWallSecondsTotal.inc({ span }, wallSeconds);
322
+ m.syncSpanDuration.observe({ span }, wallSeconds);
323
+ }
324
+ };
219
325
  var metricsMiddleware = () => {
220
326
  return (req, res, next) => {
221
327
  if (!metricsEnabled()) return next();
@@ -256,6 +362,7 @@ var metricsHandler = (env) => {
256
362
  };
257
363
  //#endregion
258
364
  exports.getMetrics = getMetrics;
365
+ exports.measureSync = measureSync;
259
366
  exports.metricsEnabled = metricsEnabled;
260
367
  exports.metricsHandler = metricsHandler;
261
368
  exports.metricsMiddleware = metricsMiddleware;
@@ -264,6 +371,7 @@ exports.recordBotScore = recordBotScore;
264
371
  exports.recordCaptchaIssueError = recordCaptchaIssueError;
265
372
  exports.recordCaptchaIssued = recordCaptchaIssued;
266
373
  exports.recordCaptchaVerify = recordCaptchaVerify;
374
+ exports.recordDecoderOutcome = recordDecoderOutcome;
267
375
  exports.recordDetectorTriggered = recordDetectorTriggered;
268
376
  exports.recordDomainValidation = recordDomainValidation;
269
377
  exports.recordFrictionlessDecision = recordFrictionlessDecision;
@@ -11,6 +11,7 @@ const require_handshakeTimingMiddleware = require("./handshakeTimingMiddleware.c
11
11
  const require_public = require("./public.cjs");
12
12
  const require_domainMiddleware = require("./domainMiddleware.cjs");
13
13
  const require_createApiAdminRoutesProvider = require("./admin/createApiAdminRoutesProvider.cjs");
14
+ const require_cpuProfiler = require("./cpuProfiler.cjs");
14
15
  const require_headerCheckMiddleware = require("./headerCheckMiddleware.cjs");
15
16
  const require_ignoreMiddleware = require("./ignoreMiddleware.cjs");
16
17
  const require_ipInfoMiddleware = require("./ipInfoMiddleware.cjs");
@@ -165,6 +166,7 @@ async function startProviderApi(env, admin = false, port) {
165
166
  apiApp.use(express.default.json({ limit: "1mb" }));
166
167
  apiApp.use(require_public.publicRouter(env));
167
168
  apiApp.use(require_metrics.metricsMiddleware());
169
+ require_cpuProfiler.startCpuProfiler(env.logger);
168
170
  if (!(process.env.NODE_ENV === "test" || env.config.defaultEnvironment === "development")) {
169
171
  const rateLimits = {
170
172
  ...env.config.rateLimits,
@@ -1,6 +1,7 @@
1
1
  const require_compositeIpAddress = require("../compositeIpAddress.cjs");
2
2
  const require_blacklistRequestInspector = require("../api/blacklistRequestInspector.cjs");
3
3
  const require_bundlePool = require("./detection/bundlePool.cjs");
4
+ const require_decoderPool = require("./detection/decoderPool.cjs");
4
5
  const require_checkTrafficFilter = require("./spam/checkTrafficFilter.cjs");
5
6
  const require_enrichDnsEvent = require("./dnsEvent/enrichDnsEvent.cjs");
6
7
  const require_checkSpamEmail = require("./spam/checkSpamEmail.cjs");
@@ -115,6 +116,56 @@ var CaptchaManager = class CaptchaManager {
115
116
  };
116
117
  }
117
118
  /**
119
+ * Decode the payloads a solution submission can carry, in one pass.
120
+ *
121
+ * Both decodes want the same session bundle and neither depends on the
122
+ * other, so the bundle is resolved once and the two decodes run together.
123
+ * Done separately they cost two bundle lookups and two serialised decoder
124
+ * round trips on the submit path, which measured as a ~22-25% latency
125
+ * regression on `pow/solution` and `puzzle/solution` once decoding moved
126
+ * to worker threads.
127
+ *
128
+ * Absent or undecodable payloads come back undefined rather than throwing,
129
+ * matching what the individual decoders already do: a submission is not
130
+ * worth failing over a signal we could not read.
131
+ */
132
+ async decodeSubmissionPayloads(sessionId, payloads) {
133
+ const { behavioural, simd } = payloads;
134
+ if (!behavioural && !simd) return {};
135
+ let bundle;
136
+ try {
137
+ bundle = await this.resolveBundleBySessionId(sessionId);
138
+ } catch (err) {
139
+ this.logger?.warn(() => ({
140
+ msg: "Could not resolve the detector bundle for a submission",
141
+ data: { sessionId },
142
+ err
143
+ }));
144
+ return {};
145
+ }
146
+ const tolerate = async (decoding, which) => {
147
+ if (!decoding) return void 0;
148
+ try {
149
+ return await decoding ?? void 0;
150
+ } catch (err) {
151
+ this.logger?.warn(() => ({
152
+ msg: "Failed to decode a submission payload",
153
+ data: {
154
+ decoder: which,
155
+ sessionId
156
+ },
157
+ err
158
+ }));
159
+ return;
160
+ }
161
+ };
162
+ const [decodedBehavioural, decodedSimd] = await Promise.all([tolerate(behavioural ? this.decryptBehavioralData(behavioural, bundle) : void 0, "behaviour"), tolerate(simd ? this.decryptSimdReadingsForAttach(simd, bundle) : void 0, "simd")]);
163
+ return {
164
+ ...decodedBehavioural && { behavioural: decodedBehavioural },
165
+ ...decodedSimd && { simd: decodedSimd }
166
+ };
167
+ }
168
+ /**
118
169
  * Decrypt + first-hop-wins attach. Resolves the session's detector pool
119
170
  * bundle (promoted onto the session record at frictionless time) to decrypt.
120
171
  * No-op on decrypt failure / no bundle.
@@ -409,9 +460,12 @@ var CaptchaManager = class CaptchaManager {
409
460
  this.logger?.warn(() => ({ msg: "No detector bundle resolved for SIMD readings — dropping field" }));
410
461
  return null;
411
462
  }
412
- const decryptSimdReadings = (await Promise.resolve().then(() => require("./detection/decodeSimd.cjs"))).default;
413
463
  try {
414
- return await decryptSimdReadings(encryptedData, bundle.key, bundle.innerConfig);
464
+ return await require_decoderPool.decode("simd", [
465
+ encryptedData,
466
+ bundle.key,
467
+ bundle.innerConfig
468
+ ]);
415
469
  } catch (err) {
416
470
  this.logger?.warn(() => ({
417
471
  msg: "Failed to decrypt SIMD readings with the session's bundle",
@@ -425,9 +479,12 @@ var CaptchaManager = class CaptchaManager {
425
479
  this.logger?.warn(() => ({ msg: "No detector bundle resolved for behavioral data — dropping field" }));
426
480
  return null;
427
481
  }
428
- const decryptBehavioralData = (await Promise.resolve().then(() => require("./detection/decodeBehavior.cjs"))).default;
429
482
  try {
430
- const result = await decryptBehavioralData(encryptedData, bundle.key, bundle.innerConfig);
483
+ const result = await require_decoderPool.decode("behaviour", [
484
+ encryptedData,
485
+ bundle.key,
486
+ bundle.innerConfig
487
+ ]);
431
488
  this.logger?.info(() => ({
432
489
  msg: "Behavioral data decrypted successfully",
433
490
  data: {
@@ -1,4 +1,5 @@
1
1
  const require_runtime = require("../../_virtual/_rolldown/runtime.cjs");
2
+ const require_metrics = require("../../api/metrics.cjs");
2
3
  let _prosopo_types = require("@prosopo/types");
3
4
  let node_crypto = require("node:crypto");
4
5
  let node_vm = require("node:vm");
@@ -250,7 +251,7 @@ var DecisionMachineRunner = class DecisionMachineRunner {
250
251
  if (options.optional) return void 0;
251
252
  throw new Error(`Decision machine must export one of: ${exportNames.join(", ")}`);
252
253
  }
253
- const result = await this.withTimeout(Promise.resolve(named.fn(options.input)), EXEC_TIMEOUT_MS);
254
+ const result = await this.withTimeout(Promise.resolve(require_metrics.measureSync("decision_machine_decide", () => named.fn(options.input))), EXEC_TIMEOUT_MS);
254
255
  const parsed = schema.safeParse(result);
255
256
  if (!parsed.success) throw new Error(`Decision machine '${named.name}' output failed validation`);
256
257
  return parsed.data;
@@ -0,0 +1,14 @@
1
+ var FILENAMES = {
2
+ payload: "decodePayload.js",
3
+ simd: "decodeSimd.js",
4
+ behaviour: "decodeBehavior.js"
5
+ };
6
+ var decoderModuleUrl = (decoder) => new URL(`./${FILENAMES[decoder]}`, require("url").pathToFileURL(__filename).href).href;
7
+ var decoderModuleUrls = () => ({
8
+ payload: decoderModuleUrl("payload"),
9
+ simd: decoderModuleUrl("simd"),
10
+ behaviour: decoderModuleUrl("behaviour")
11
+ });
12
+ //#endregion
13
+ exports.decoderModuleUrl = decoderModuleUrl;
14
+ exports.decoderModuleUrls = decoderModuleUrls;
@@ -0,0 +1,183 @@
1
+ const require_metrics = require("../../api/metrics.cjs");
2
+ const require_decoderModules = require("./decoderModules.cjs");
3
+ let _prosopo_logger = require("@prosopo/logger");
4
+ let node_os = require("node:os");
5
+ let node_worker_threads = require("node:worker_threads");
6
+ //#region src/tasks/detection/decoderPool.ts
7
+ var logger = (0, _prosopo_logger.getLogger)("info", "provider:decoder-pool");
8
+ var DEFAULT_TIMEOUT_MS = 5e3;
9
+ var numberFromEnv = (name, fallback) => {
10
+ const raw = process.env[name];
11
+ if (raw === void 0 || raw === "") return fallback;
12
+ const parsed = Number.parseInt(raw, 10);
13
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
14
+ };
15
+ /**
16
+ * Workers to run. Defaults to a small share of the box, since decoding is one
17
+ * of several things the node does and the rest of it still needs cores.
18
+ *
19
+ * `PROSOPO_DECODER_WORKERS=0` runs every decode inline on the main thread —
20
+ * the behaviour before this existed. That is the kill switch: it needs a
21
+ * restart but not a rollback.
22
+ */
23
+ var poolSize = () => numberFromEnv("PROSOPO_DECODER_WORKERS", Math.max(1, Math.min(4, (0, node_os.availableParallelism)() - 1)));
24
+ var timeoutMs = () => numberFromEnv("PROSOPO_DECODER_TIMEOUT_MS", DEFAULT_TIMEOUT_MS);
25
+ var WORKER_SOURCE = `
26
+ import { parentPort, workerData } from "node:worker_threads";
27
+
28
+ const decoders = {};
29
+ for (const [name, url] of Object.entries(workerData.urls)) {
30
+ decoders[name] = (await import(url)).default;
31
+ }
32
+ parentPort.postMessage({ ready: true });
33
+
34
+ parentPort.on("message", async ({ id, decoder, args }) => {
35
+ try {
36
+ const result = await decoders[decoder](...args);
37
+ parentPort.postMessage({ id, ok: true, result });
38
+ } catch (err) {
39
+ parentPort.postMessage({
40
+ id,
41
+ ok: false,
42
+ message: err instanceof Error ? err.message : String(err),
43
+ });
44
+ }
45
+ });
46
+ `;
47
+ var pool;
48
+ var nextCallId = 0;
49
+ var nextWorker = 0;
50
+ var inlineFallback = false;
51
+ var spawn = () => {
52
+ const worker = new node_worker_threads.Worker(WORKER_SOURCE, {
53
+ eval: true,
54
+ workerData: { urls: require_decoderModules.decoderModuleUrls() }
55
+ });
56
+ const pooled = {
57
+ worker,
58
+ inFlight: /* @__PURE__ */ new Map(),
59
+ ready: new Promise((resolve, reject) => {
60
+ worker.once("message", () => resolve());
61
+ worker.once("error", reject);
62
+ })
63
+ };
64
+ worker.on("message", (msg) => {
65
+ if (msg.id === void 0) return;
66
+ const pending = pooled.inFlight.get(msg.id);
67
+ if (!pending) return;
68
+ pooled.inFlight.delete(msg.id);
69
+ clearTimeout(pending.timer);
70
+ if (msg.ok) pending.resolve(msg.result);
71
+ else pending.reject(new Error(msg.message ?? "decoder failed"));
72
+ });
73
+ const abandon = (reason) => {
74
+ for (const [id, pending] of pooled.inFlight) {
75
+ clearTimeout(pending.timer);
76
+ pending.reject(new Error(reason));
77
+ pooled.inFlight.delete(id);
78
+ }
79
+ replace(pooled);
80
+ };
81
+ worker.on("error", (err) => {
82
+ logger.error(() => ({
83
+ msg: "Decoder worker errored",
84
+ err
85
+ }));
86
+ abandon(`decoder worker errored: ${err.message}`);
87
+ });
88
+ worker.on("exit", (code) => {
89
+ if (code === 0) return;
90
+ logger.error(() => ({
91
+ msg: "Decoder worker exited",
92
+ data: { code }
93
+ }));
94
+ abandon(`decoder worker exited with code ${code}`);
95
+ });
96
+ worker.unref();
97
+ return pooled;
98
+ };
99
+ var replace = (dead) => {
100
+ if (!pool) return;
101
+ const index = pool.indexOf(dead);
102
+ if (index === -1) return;
103
+ try {
104
+ pool[index] = spawn();
105
+ } catch (err) {
106
+ logger.error(() => ({
107
+ msg: "Could not replace decoder worker",
108
+ err
109
+ }));
110
+ pool.splice(index, 1);
111
+ }
112
+ };
113
+ var getPool = () => {
114
+ if (inlineFallback) return void 0;
115
+ if (pool) return pool.length > 0 ? pool : void 0;
116
+ const size = poolSize();
117
+ if (size === 0) {
118
+ inlineFallback = true;
119
+ logger.info(() => ({ msg: "Decoder workers disabled, decoding inline" }));
120
+ return;
121
+ }
122
+ try {
123
+ pool = Array.from({ length: size }, () => spawn());
124
+ logger.info(() => ({
125
+ msg: "Decoder pool started",
126
+ data: { size }
127
+ }));
128
+ return pool;
129
+ } catch (err) {
130
+ inlineFallback = true;
131
+ logger.error(() => ({
132
+ msg: "Could not start decoder pool, falling back to inline decoding",
133
+ err
134
+ }));
135
+ return;
136
+ }
137
+ };
138
+ var runInline = async (decoder, args) => {
139
+ return (await import(require_decoderModules.decoderModuleUrl(decoder))).default(...args);
140
+ };
141
+ var runOnWorker = async (pooled, decoder, args) => {
142
+ await pooled.ready;
143
+ const id = nextCallId++;
144
+ return new Promise((resolve, reject) => {
145
+ const timer = setTimeout(() => {
146
+ pooled.inFlight.delete(id);
147
+ pooled.worker.terminate();
148
+ reject(/* @__PURE__ */ new Error(`decoder '${decoder}' timed out`));
149
+ }, timeoutMs());
150
+ timer.unref?.();
151
+ pooled.inFlight.set(id, {
152
+ resolve,
153
+ reject,
154
+ timer
155
+ });
156
+ pooled.worker.postMessage({
157
+ id,
158
+ decoder,
159
+ args
160
+ });
161
+ });
162
+ };
163
+ /**
164
+ * Decode off the event loop, falling back to inline decoding if the pool is
165
+ * unavailable. Either way the contract is the one the callers already handle:
166
+ * resolves with the decoder's result, rejects with its error.
167
+ */
168
+ var decode = async (decoder, args) => {
169
+ const started = process.hrtime.bigint();
170
+ const workers = getPool();
171
+ const pooled = workers?.[nextWorker++ % workers.length];
172
+ try {
173
+ const result = pooled ? await runOnWorker(pooled, decoder, args) : await runInline(decoder, args);
174
+ require_metrics.recordDecoderOutcome(decoder, "ok", elapsedSeconds(started));
175
+ return result;
176
+ } catch (err) {
177
+ require_metrics.recordDecoderOutcome(decoder, "error", elapsedSeconds(started));
178
+ throw err;
179
+ }
180
+ };
181
+ var elapsedSeconds = (started) => Number(process.hrtime.bigint() - started) / 1e9;
182
+ //#endregion
183
+ exports.decode = decode;
@@ -1,4 +1,4 @@
1
- const require_decodePayload = require("./decodePayload.cjs");
1
+ const require_decoderPool = require("./decoderPool.cjs");
2
2
  //#region src/tasks/detection/getBotScore.ts
3
3
  var MAX_KEYS = 64;
4
4
  var MAX_KEY_LENGTH = 64;
@@ -60,7 +60,14 @@ var sanitiseDetectorData = (value) => {
60
60
  return sanitised;
61
61
  };
62
62
  var getBotScore = async (payload, headHash, privateKeyString, innerConfigEncoded, payloadLayoutEncoded, keyMapEncoded) => {
63
- const result = await require_decodePayload.default(payload, headHash, privateKeyString, innerConfigEncoded, payloadLayoutEncoded, keyMapEncoded);
63
+ const result = await require_decoderPool.decode("payload", [
64
+ payload,
65
+ headHash,
66
+ privateKeyString,
67
+ innerConfigEncoded,
68
+ payloadLayoutEncoded,
69
+ keyMapEncoded
70
+ ]);
64
71
  const baseBotScore = result.score;
65
72
  if (baseBotScore === void 0 || Number.isNaN(baseBotScore)) return {
66
73
  baseBotScore: 1,
@@ -109,8 +109,11 @@ var ImgCaptchaManager = class extends require_captchaManager.CaptchaManager {
109
109
  };
110
110
  const storedClientMetaData = require_clientMetaData.toStoredClientMetaData(clientMetaData);
111
111
  const pendingRecord = await this.db.getPendingImageCommitment(requestHash);
112
- const sessionBundle = await this.resolveBundleBySessionId(pendingRecord?.sessionId);
113
- const decodedSimdReadings = simdReadings ? await this.decryptSimdReadingsForAttach(simdReadings, sessionBundle) : void 0;
112
+ const decodedPayloads = await this.decodeSubmissionPayloads(pendingRecord?.sessionId, {
113
+ behavioural: behavioralData,
114
+ simd: simdReadings
115
+ });
116
+ const decodedSimdReadings = decodedPayloads.simd;
114
117
  const pushSimdAttachIfAny = (sessionId, writes) => {
115
118
  if (decodedSimdReadings) writes.push(this.recordSessionSimdReadingsIfAbsentWithCache(sessionId, decodedSimdReadings, _prosopo_types.SimdReadingsStage.submit));
116
119
  };
@@ -128,7 +131,7 @@ var ImgCaptchaManager = class extends require_captchaManager.CaptchaManager {
128
131
  let behavioralDataPacked;
129
132
  let deviceCapability;
130
133
  if (behavioralData) try {
131
- const decryptedData = await this.decryptBehavioralData(behavioralData, sessionBundle);
134
+ const decryptedData = decodedPayloads.behavioural;
132
135
  if (decryptedData) {
133
136
  this.logger?.info(() => ({
134
137
  msg: "Behavioral analysis completed",
@@ -1,3 +1,4 @@
1
+ const require_metrics = require("../../api/metrics.cjs");
1
2
  let _prosopo_common = require("@prosopo/common");
2
3
  let _prosopo_datasets = require("@prosopo/datasets");
3
4
  //#region src/tasks/imgCaptcha/imgCaptchaTasksUtils.ts
@@ -20,8 +21,10 @@ var buildTreeAndGetCommitmentId = (captchaSolutions) => {
20
21
  failedFuncName: buildTreeAndGetCommitmentId.name,
21
22
  commitmentId: null
22
23
  } });
23
- const solutionsHashed = captchaSolutions.map((captcha) => nativeMerkle.computeCaptchaSolutionHash(captcha.captchaId, captcha.captchaContentId, captcha.solution, captcha.salt));
24
- const layers = nativeMerkle.buildMerkleLayers(solutionsHashed);
24
+ const layers = require_metrics.measureSync("merkle_build", () => {
25
+ const solutionsHashed = captchaSolutions.map((captcha) => nativeMerkle.computeCaptchaSolutionHash(captcha.captchaId, captcha.captchaContentId, captcha.solution, captcha.salt));
26
+ return nativeMerkle.buildMerkleLayers(solutionsHashed);
27
+ });
25
28
  const tree = new _prosopo_datasets.CaptchaMerkleTree();
26
29
  tree.hydrateFromLayers(layers);
27
30
  const commitmentId = tree.root?.hash;
@@ -133,9 +133,12 @@ var PowCaptchaManager = class extends require_captchaManager.CaptchaManager {
133
133
  }
134
134
  let behavioralUpdates = {};
135
135
  let decryptedBehavioralDataPacked;
136
+ const decodedPayloads = await this.decodeSubmissionPayloads(challengeRecord.sessionId, {
137
+ behavioural: behavioralData,
138
+ simd: simdReadings
139
+ });
136
140
  if (behavioralData) try {
137
- const bundle = await this.resolveBundleBySessionId(challengeRecord.sessionId);
138
- const decryptedData = await this.decryptBehavioralData(behavioralData, bundle);
141
+ const decryptedData = decodedPayloads.behavioural;
139
142
  if (decryptedData) {
140
143
  const dappAccount = (0, _prosopo_util.at)(challengeSplit, 2);
141
144
  this.logger?.info(() => ({
@@ -182,7 +185,7 @@ var PowCaptchaManager = class extends require_captchaManager.CaptchaManager {
182
185
  ...(0, _prosopo_types.isBlockingCaptchaResult)(_prosopo_types.CaptchaType.pow, result) && { blocked: true },
183
186
  ...storedClientMetaData && { clientMetaData: storedClientMetaData }
184
187
  }));
185
- if (simdReadings) writePromises.push(this.decryptAndAttachSimdReadingsIfAbsent(linkedSessionId, simdReadings, _prosopo_types.SimdReadingsStage.submit));
188
+ if (decodedPayloads.simd) writePromises.push(this.recordSessionSimdReadingsIfAbsentWithCache(linkedSessionId, decodedPayloads.simd, _prosopo_types.SimdReadingsStage.submit));
186
189
  }
187
190
  await Promise.all(writePromises);
188
191
  if (!correct) return { verified: correct };