@prosopo/procaptcha-frictionless 2.13.0 → 2.13.1

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.
@@ -0,0 +1,115 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ /**
16
+ * Detector bundle prefetch.
17
+ *
18
+ * Since the detector moved into the provider-served pool, the frictionless flow
19
+ * cannot start until `/detector/assign` has returned. That request is issued by
20
+ * `customDetectBot`, which only runs once React has mounted the widget — and
21
+ * that mount sits behind the bundle's dynamic-import chain. Measured on a
22
+ * staging demo page the assign request did not leave the browser until 1513 ms,
23
+ * of which ~700 ms was purely waiting for chunks to arrive in sequence.
24
+ *
25
+ * Nothing in the request depends on React, i18n or the widget config: it needs
26
+ * the site key (a DOM attribute, readable immediately), the environment (a
27
+ * build-time constant) and the IP-mode flags (DOM attributes). So the entry
28
+ * point kicks it off as soon as it has read those, and `customDetectBot` picks
29
+ * up the in-flight promise instead of starting its own.
30
+ *
31
+ * The cache is deliberately single-use. A provider pin is only valid for the
32
+ * attempt it was made for — on a retry the previous pronode is the one that
33
+ * just failed — so a consumed entry is dropped and the retry re-resolves.
34
+ */
35
+
36
+ import { ProviderApi } from "@prosopo/api";
37
+ import { getProcaptchaRandomActiveProvider } from "@prosopo/procaptcha-common";
38
+ import type {
39
+ AssignDetectorBundleResponse,
40
+ EnvironmentTypes,
41
+ RandomProvider,
42
+ } from "@prosopo/types";
43
+
44
+ // `IpMode` is declared in @prosopo/load-balancer, which this package does not
45
+ // depend on. Derive it from the selector we already call rather than adding a
46
+ // dependency (and a matching tsconfig project reference) for one type alias —
47
+ // this also cannot drift from the function's real signature.
48
+ type IpModeParam = Parameters<typeof getProcaptchaRandomActiveProvider>[1];
49
+
50
+ export interface PrefetchedDetector {
51
+ provider: RandomProvider;
52
+ assigned: AssignDetectorBundleResponse;
53
+ }
54
+
55
+ const inFlight = new Map<string, Promise<PrefetchedDetector>>();
56
+
57
+ const keyOf = (
58
+ environment: EnvironmentTypes,
59
+ ipMode: IpModeParam,
60
+ siteKey: string,
61
+ ): string => `${environment}|${ipMode ?? "auto"}|${siteKey}`;
62
+
63
+ /**
64
+ * Start resolving a provider and assigning a detector bundle. Safe to call more
65
+ * than once for the same key — subsequent calls join the in-flight request.
66
+ *
67
+ * Never rejects to the caller: a failed prefetch is indistinguishable from
68
+ * never having prefetched, and `customDetectBot` already handles assign failure
69
+ * by falling back to PoW. Returning a rejected promise here would surface as an
70
+ * unhandled rejection in the host page.
71
+ */
72
+ export const prefetchDetector = (
73
+ environment: EnvironmentTypes,
74
+ ipMode: IpModeParam,
75
+ siteKey: string,
76
+ ): void => {
77
+ const key = keyOf(environment, ipMode, siteKey);
78
+ if (inFlight.has(key)) return;
79
+
80
+ const promise = (async (): Promise<PrefetchedDetector> => {
81
+ const provider = await getProcaptchaRandomActiveProvider(
82
+ environment,
83
+ ipMode,
84
+ );
85
+ const providerApi = new ProviderApi(provider.provider.url, siteKey);
86
+ const assigned = await providerApi.assignDetectorBundle(siteKey);
87
+ return { provider, assigned };
88
+ })();
89
+
90
+ // Attach a no-op catch so a failed prefetch never becomes an unhandled
91
+ // rejection. `takePrefetchedDetector`'s consumer still sees the rejection on
92
+ // the original promise and falls back.
93
+ promise.catch(() => undefined);
94
+ inFlight.set(key, promise);
95
+ };
96
+
97
+ /**
98
+ * Claim a prefetched assignment, if one was started for this exact key. The
99
+ * entry is removed, so a retry does not reuse a pin that may have just failed.
100
+ */
101
+ export const takePrefetchedDetector = (
102
+ environment: EnvironmentTypes,
103
+ ipMode: IpModeParam,
104
+ siteKey: string,
105
+ ): Promise<PrefetchedDetector> | undefined => {
106
+ const key = keyOf(environment, ipMode, siteKey);
107
+ const promise = inFlight.get(key);
108
+ if (promise) inFlight.delete(key);
109
+ return promise;
110
+ };
111
+
112
+ /** Test seam — drops any in-flight prefetches. */
113
+ export const clearPrefetchedDetectors = (): void => {
114
+ inFlight.clear();
115
+ };
package/src/index.ts CHANGED
@@ -12,3 +12,4 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
  export * from "./ProcaptchaFrictionless.js";
15
+ export * from "./detectorPrefetch.js";
@@ -0,0 +1,114 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { EnvironmentTypes } from "@prosopo/types";
16
+ import { afterEach, describe, expect, it, vi } from "vitest";
17
+
18
+ const assignDetectorBundle = vi.fn();
19
+ const getProcaptchaRandomActiveProvider = vi.fn();
20
+
21
+ vi.mock("@prosopo/api", () => ({
22
+ ProviderApi: class {
23
+ assignDetectorBundle = assignDetectorBundle;
24
+ },
25
+ }));
26
+
27
+ vi.mock("@prosopo/procaptcha-common", () => ({
28
+ getProcaptchaRandomActiveProvider: (
29
+ ...args: [EnvironmentTypes, string | undefined]
30
+ ) => getProcaptchaRandomActiveProvider(...args),
31
+ }));
32
+
33
+ const { prefetchDetector, takePrefetchedDetector, clearPrefetchedDetectors } =
34
+ await import("../detectorPrefetch.js");
35
+
36
+ const ENV = "staging" as EnvironmentTypes;
37
+ const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
38
+
39
+ const provider = { provider: { url: "https://pronode.example" } };
40
+
41
+ afterEach(() => {
42
+ clearPrefetchedDetectors();
43
+ vi.clearAllMocks();
44
+ });
45
+
46
+ describe("detectorPrefetch", () => {
47
+ it("returns undefined when nothing was prefetched", () => {
48
+ expect(takePrefetchedDetector(ENV, undefined, SITE_KEY)).toBeUndefined();
49
+ });
50
+
51
+ it("resolves the provider and assigns a bundle", async () => {
52
+ getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
53
+ assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });
54
+
55
+ prefetchDetector(ENV, undefined, SITE_KEY);
56
+ const claimed = takePrefetchedDetector(ENV, undefined, SITE_KEY);
57
+ expect(claimed).toBeDefined();
58
+
59
+ const result = await (claimed as Promise<unknown>);
60
+ expect(result).toStrictEqual({
61
+ provider,
62
+ assigned: { useProviderBundle: true },
63
+ });
64
+ expect(assignDetectorBundle).toHaveBeenCalledWith(SITE_KEY);
65
+ });
66
+
67
+ it("is single-use, so a retry does not reuse a stale provider pin", async () => {
68
+ getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
69
+ assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });
70
+
71
+ prefetchDetector(ENV, undefined, SITE_KEY);
72
+ const first = takePrefetchedDetector(ENV, undefined, SITE_KEY);
73
+ await (first as Promise<unknown>);
74
+
75
+ expect(takePrefetchedDetector(ENV, undefined, SITE_KEY)).toBeUndefined();
76
+ });
77
+
78
+ it("does not start a second request for the same key", () => {
79
+ getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
80
+ assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });
81
+
82
+ prefetchDetector(ENV, undefined, SITE_KEY);
83
+ prefetchDetector(ENV, undefined, SITE_KEY);
84
+
85
+ expect(getProcaptchaRandomActiveProvider).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ it("keys on site key and ip mode, so a different widget does not claim it", () => {
89
+ getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
90
+ assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });
91
+
92
+ prefetchDetector(ENV, "ipv4", SITE_KEY);
93
+
94
+ expect(takePrefetchedDetector(ENV, "ipv6", SITE_KEY)).toBeUndefined();
95
+ expect(takePrefetchedDetector(ENV, undefined, SITE_KEY)).toBeUndefined();
96
+ expect(takePrefetchedDetector(ENV, "ipv4", "other-key")).toBeUndefined();
97
+ expect(takePrefetchedDetector(ENV, "ipv4", SITE_KEY)).toBeDefined();
98
+ });
99
+
100
+ it("surfaces failure to the claimant without an unhandled rejection", async () => {
101
+ getProcaptchaRandomActiveProvider.mockRejectedValue(
102
+ new Error("no providers"),
103
+ );
104
+
105
+ prefetchDetector(ENV, undefined, SITE_KEY);
106
+ const claimed = takePrefetchedDetector(ENV, undefined, SITE_KEY);
107
+ expect(claimed).toBeDefined();
108
+
109
+ // customDetectBot awaits this inside a try/catch and falls back; the point
110
+ // here is that it rejects rather than hanging, and that the no-op catch
111
+ // attached at prefetch time did not swallow it for the real consumer.
112
+ await expect(claimed as Promise<unknown>).rejects.toThrow("no providers");
113
+ });
114
+ });