Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/parallel-detector-prefetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@prosopo/procaptcha-frictionless": patch
"@prosopo/procaptcha-bundle": patch
---

Start the detector-bundle assignment at page load instead of after the widget mounts.

Since the detector moved into the provider-served pool, the frictionless flow cannot begin until `/detector/assign` returns. That request was issued by `customDetectBot`, which only runs once React has mounted the widget — so it queued behind the bundle's dynamic-import chain. Measured on the staging demo, `assign` did not leave the browser until **1513 ms**, of which ~700 ms was purely waiting for chunks to arrive in sequence.

Nothing in that request depends on React, i18n or the widget config: it needs the site key (a DOM attribute), the environment (a build-time constant) and the IP-mode flags (DOM attributes). The bundle entry now kicks it off as soon as it has read those, and `customDetectBot` claims the in-flight promise instead of starting its own.

The prefetch is loaded by dynamic import so the provider selector and API client do not land in the entry chunk and delay first paint; the entry grows by ~400 bytes. It is fire-and-forget — a failed prefetch is indistinguishable from no prefetch, and the existing fallback path still resolves a provider itself.

The cache is single-use and keyed on `(environment, ipMode, siteKey)`, so a retry — which is retrying precisely because the pinned pronode failed — re-resolves rather than reusing a stale pin, and a second widget with different flags cannot claim another's assignment.
38 changes: 37 additions & 1 deletion packages/procaptcha-bundle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

import { getWindowCallback } from "@prosopo/procaptcha-common";
import type { ProcaptchaRenderOptions } from "@prosopo/types";
import type { EnvironmentTypes, ProcaptchaRenderOptions } from "@prosopo/types";
import { at } from "@prosopo/util";
import type { Root } from "react-dom/client";
import { extractParams, getProcaptchaScript } from "./util/config.js";
Expand All @@ -25,6 +25,35 @@ let procaptchaRoots: Root[] = [];

const widgetFactory = new WidgetFactory(new WidgetThemeResolver());

/**
* Kick off provider resolution + detector assignment without blocking render.
*
* Loaded via dynamic import on purpose: it pulls in the provider selector and
* the provider API client, and importing those statically would push them into
* the entry chunk and delay first paint. Firing the import here starts that
* download in parallel with the widget's own chunks rather than after them.
*
* Fire-and-forget by design — a failed prefetch is indistinguishable from no
* prefetch, and the detection path falls back to resolving a provider itself.
*/
const startDetectorPrefetch = (
siteKey: string,
flags: { ipv4?: boolean; ipv6?: boolean },
): void => {
void Promise.all([
import("@prosopo/procaptcha-frictionless"),
import("@prosopo/procaptcha-common"),
])
.then(([frictionless, common]) => {
frictionless.prefetchDetector(
process.env.PROSOPO_DEFAULT_ENVIRONMENT as EnvironmentTypes,
common.pickIpMode(flags),
siteKey,
);
})
.catch(() => undefined);
};

// Define a custom event name for procaptcha execution
const PROCAPTCHA_EXECUTE_EVENT = "procaptcha:execute";

Expand All @@ -50,6 +79,13 @@ const implicitRender = async () => {
return;
}

// Everything the detector assignment needs is known right here: the site
// key and IP-mode flags come off the DOM, the environment is a build-time
// constant. Start it now so the round-trip overlaps the widget's own
// dynamic-import chain instead of queueing behind it —
// `customDetectBot` claims the in-flight promise when it eventually runs.
startDetectorPrefetch(siteKey, { ipv4, ipv6 });

const root = await widgetFactory.createWidgets(
elements,
{
Expand Down
54 changes: 45 additions & 9 deletions packages/procaptcha-frictionless/src/customDetectBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import {
DetectorLoaderFromScript,
type DetectorType,
} from "./detectorLoader.js";
import {
type PrefetchedDetector,
takePrefetchedDetector,
} from "./detectorPrefetch.js";

// Upper bound on the detector-bundle assignment + load probe. The detector
// lives ONLY in the provider-served pool bundles, so if the provider is
Expand Down Expand Up @@ -168,11 +172,39 @@ const customDetectBot: BotDetectionFunction = async (
// preference so frictionless and the subsequent captcha hops stay on the
// same stack. Resolved up front — before detection rather than alongside it —
// because the detector bundle is served BY this provider.
const provider = await getProcaptchaRandomActiveProvider(
config.defaultEnvironment,
ipMode,
retryContext,
);
// The bundle entry starts provider resolution + assign as soon as it has read
// the site key off the DOM, which is well before React has mounted this
// widget. Claim that in-flight work if it exists rather than repeating it.
// Only valid on a first attempt: a retry is retrying *because* the pinned
// pronode failed, so it must re-resolve.
const isFirstAttempt = !retryContext || retryContext.attempt <= 1;
const prefetched = isFirstAttempt
? takePrefetchedDetector(
config.defaultEnvironment,
ipMode,
config.account.address,
)
: undefined;

// A prefetch that failed must not fail the flow — it is an optimisation, and
// the normal path below handles provider selection and assign failure
// already. So swallow it and re-resolve.
let prefetchedResult: PrefetchedDetector | undefined;
if (prefetched) {
try {
prefetchedResult = await prefetched;
} catch {
prefetchedResult = undefined;
}
}

const provider =
prefetchedResult?.provider ??
(await getProcaptchaRandomActiveProvider(
config.defaultEnvironment,
ipMode,
retryContext,
));

const providerApi = new ProviderApi(
provider.provider.url,
Expand All @@ -188,10 +220,14 @@ const customDetectBot: BotDetectionFunction = async (
let detectorSessionId: string | undefined;
let providerDetect: DetectorType | undefined;
try {
const assigned = await withTimeout(
providerApi.assignDetectorBundle(config.account.address),
ASSIGN_TIMEOUT_MS,
);
// Reuse the prefetched assignment when the entry already fetched one for
// this provider; otherwise issue it now.
const assigned =
prefetchedResult?.assigned ??
(await withTimeout(
providerApi.assignDetectorBundle(config.account.address),
ASSIGN_TIMEOUT_MS,
));
if (assigned.useProviderBundle && assigned.detectorScript) {
detectorSessionId = assigned.detectorSessionId;
providerDetect = await withTimeout(
Expand Down
115 changes: 115 additions & 0 deletions packages/procaptcha-frictionless/src/detectorPrefetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright 2021-2026 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
* Detector bundle prefetch.
*
* Since the detector moved into the provider-served pool, the frictionless flow
* cannot start until `/detector/assign` has returned. That request is issued by
* `customDetectBot`, which only runs once React has mounted the widget — and
* that mount sits behind the bundle's dynamic-import chain. Measured on a
* staging demo page the assign request did not leave the browser until 1513 ms,
* of which ~700 ms was purely waiting for chunks to arrive in sequence.
*
* Nothing in the request depends on React, i18n or the widget config: it needs
* the site key (a DOM attribute, readable immediately), the environment (a
* build-time constant) and the IP-mode flags (DOM attributes). So the entry
* point kicks it off as soon as it has read those, and `customDetectBot` picks
* up the in-flight promise instead of starting its own.
*
* The cache is deliberately single-use. A provider pin is only valid for the
* attempt it was made for — on a retry the previous pronode is the one that
* just failed — so a consumed entry is dropped and the retry re-resolves.
*/

import { ProviderApi } from "@prosopo/api";
import { getProcaptchaRandomActiveProvider } from "@prosopo/procaptcha-common";
import type {
AssignDetectorBundleResponse,
EnvironmentTypes,
RandomProvider,
} from "@prosopo/types";

// `IpMode` is declared in @prosopo/load-balancer, which this package does not
// depend on. Derive it from the selector we already call rather than adding a
// dependency (and a matching tsconfig project reference) for one type alias —
// this also cannot drift from the function's real signature.
type IpModeParam = Parameters<typeof getProcaptchaRandomActiveProvider>[1];

export interface PrefetchedDetector {
provider: RandomProvider;
assigned: AssignDetectorBundleResponse;
}

const inFlight = new Map<string, Promise<PrefetchedDetector>>();

const keyOf = (
environment: EnvironmentTypes,
ipMode: IpModeParam,
siteKey: string,
): string => `${environment}|${ipMode ?? "auto"}|${siteKey}`;

/**
* Start resolving a provider and assigning a detector bundle. Safe to call more
* than once for the same key — subsequent calls join the in-flight request.
*
* Never rejects to the caller: a failed prefetch is indistinguishable from
* never having prefetched, and `customDetectBot` already handles assign failure
* by falling back to PoW. Returning a rejected promise here would surface as an
* unhandled rejection in the host page.
*/
export const prefetchDetector = (
environment: EnvironmentTypes,
ipMode: IpModeParam,
siteKey: string,
): void => {
const key = keyOf(environment, ipMode, siteKey);
if (inFlight.has(key)) return;

const promise = (async (): Promise<PrefetchedDetector> => {
const provider = await getProcaptchaRandomActiveProvider(
environment,
ipMode,
);
const providerApi = new ProviderApi(provider.provider.url, siteKey);
const assigned = await providerApi.assignDetectorBundle(siteKey);
return { provider, assigned };
})();

// Attach a no-op catch so a failed prefetch never becomes an unhandled
// rejection. `takePrefetchedDetector`'s consumer still sees the rejection on
// the original promise and falls back.
promise.catch(() => undefined);
inFlight.set(key, promise);
};

/**
* Claim a prefetched assignment, if one was started for this exact key. The
* entry is removed, so a retry does not reuse a pin that may have just failed.
*/
export const takePrefetchedDetector = (
environment: EnvironmentTypes,
ipMode: IpModeParam,
siteKey: string,
): Promise<PrefetchedDetector> | undefined => {
const key = keyOf(environment, ipMode, siteKey);
const promise = inFlight.get(key);
if (promise) inFlight.delete(key);
return promise;
};

/** Test seam — drops any in-flight prefetches. */
export const clearPrefetchedDetectors = (): void => {
inFlight.clear();
};
1 change: 1 addition & 0 deletions packages/procaptcha-frictionless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
// See the License for the specific language governing permissions and
// limitations under the License.
export * from "./ProcaptchaFrictionless.js";
export * from "./detectorPrefetch.js";
114 changes: 114 additions & 0 deletions packages/procaptcha-frictionless/src/tests/detectorPrefetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright 2021-2026 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import type { EnvironmentTypes } from "@prosopo/types";
import { afterEach, describe, expect, it, vi } from "vitest";

const assignDetectorBundle = vi.fn();
const getProcaptchaRandomActiveProvider = vi.fn();

vi.mock("@prosopo/api", () => ({
ProviderApi: class {
assignDetectorBundle = assignDetectorBundle;
},
}));

vi.mock("@prosopo/procaptcha-common", () => ({
getProcaptchaRandomActiveProvider: (
...args: [EnvironmentTypes, string | undefined]
) => getProcaptchaRandomActiveProvider(...args),
}));

const { prefetchDetector, takePrefetchedDetector, clearPrefetchedDetectors } =
await import("../detectorPrefetch.js");

const ENV = "staging" as EnvironmentTypes;
const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";

const provider = { provider: { url: "https://pronode.example" } };

afterEach(() => {
clearPrefetchedDetectors();
vi.clearAllMocks();
});

describe("detectorPrefetch", () => {
it("returns undefined when nothing was prefetched", () => {
expect(takePrefetchedDetector(ENV, undefined, SITE_KEY)).toBeUndefined();
});

it("resolves the provider and assigns a bundle", async () => {
getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });

prefetchDetector(ENV, undefined, SITE_KEY);
const claimed = takePrefetchedDetector(ENV, undefined, SITE_KEY);
expect(claimed).toBeDefined();

const result = await (claimed as Promise<unknown>);
expect(result).toStrictEqual({
provider,
assigned: { useProviderBundle: true },
});
expect(assignDetectorBundle).toHaveBeenCalledWith(SITE_KEY);
});

it("is single-use, so a retry does not reuse a stale provider pin", async () => {
getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });

prefetchDetector(ENV, undefined, SITE_KEY);
const first = takePrefetchedDetector(ENV, undefined, SITE_KEY);
await (first as Promise<unknown>);

expect(takePrefetchedDetector(ENV, undefined, SITE_KEY)).toBeUndefined();
});

it("does not start a second request for the same key", () => {
getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });

prefetchDetector(ENV, undefined, SITE_KEY);
prefetchDetector(ENV, undefined, SITE_KEY);

expect(getProcaptchaRandomActiveProvider).toHaveBeenCalledTimes(1);
});

it("keys on site key and ip mode, so a different widget does not claim it", () => {
getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });

prefetchDetector(ENV, "ipv4", SITE_KEY);

expect(takePrefetchedDetector(ENV, "ipv6", SITE_KEY)).toBeUndefined();
expect(takePrefetchedDetector(ENV, undefined, SITE_KEY)).toBeUndefined();
expect(takePrefetchedDetector(ENV, "ipv4", "other-key")).toBeUndefined();
expect(takePrefetchedDetector(ENV, "ipv4", SITE_KEY)).toBeDefined();
});

it("surfaces failure to the claimant without an unhandled rejection", async () => {
getProcaptchaRandomActiveProvider.mockRejectedValue(
new Error("no providers"),
);

prefetchDetector(ENV, undefined, SITE_KEY);
const claimed = takePrefetchedDetector(ENV, undefined, SITE_KEY);
expect(claimed).toBeDefined();

// customDetectBot awaits this inside a try/catch and falls back; the point
// here is that it rejects rather than hanging, and that the no-op catch
// attached at prefetch time did not swallow it for the real consumer.
await expect(claimed as Promise<unknown>).rejects.toThrow("no providers");
});
});
Loading