Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions .changeset/automatic-bot-protection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@formrelay/core": minor
"@formrelay/vue": minor
"@formrelay/nuxt": minor
---

Add automatic bot protection. Pass a `botProtectionContainer` ref to `useFormRelay` and the SDK handles widget loading, token acquisition, expiry renewal, reset re-initialization, and cleanup automatically.

New `@formrelay/core/bot-protection` entrypoint exports `loadBotProtectionWidget` and `runTokenLoop` for framework-agnostic widget lifecycle management.
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
"require": "./dist/index.cjs",
"types": "./dist/index.d.mts"
},
"./bot-protection": {
"import": "./dist/bot-protection.mjs",
"require": "./dist/bot-protection.cjs",
"types": "./dist/bot-protection.d.mts"
},
"./recaptcha-v2": {
"import": "./dist/recaptcha-v2.mjs",
"require": "./dist/recaptcha-v2.cjs",
Expand Down
215 changes: 215 additions & 0 deletions packages/core/src/bot-protection/auto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* @vitest-environment jsdom
*/
import { describe, test, expect, vi, beforeEach } from "vitest";

const mockWidget = {
getToken: vi.fn(),
reset: vi.fn(),
remove: vi.fn(),
};

vi.mock("./turnstile", () => ({
loadTurnstile: vi.fn().mockResolvedValue(mockWidget),
}));

vi.mock("./recaptcha-v2", () => ({
loadRecaptchaV2: vi.fn().mockResolvedValue(mockWidget),
}));

vi.mock("./recaptcha-v3", () => ({
loadRecaptchaV3: vi.fn().mockResolvedValue(mockWidget),
}));

import { loadBotProtectionWidget, runTokenLoop } from "./auto";

beforeEach(() => {
vi.clearAllMocks();
});

describe("loadBotProtectionWidget", () => {
test("loads turnstile with siteKey and container", async () => {
const container = document.createElement("div");
await loadBotProtectionWidget({ type: "turnstile", siteKey: "0x-key" }, container);

const { loadTurnstile } = await import("./turnstile");
expect(loadTurnstile).toHaveBeenCalledWith({ siteKey: "0x-key", container });
});

test("loads recaptcha v2 with siteKey and container", async () => {
const container = document.createElement("div");
await loadBotProtectionWidget({ type: "recaptcha_v2", siteKey: "rc-key" }, container);

const { loadRecaptchaV2 } = await import("./recaptcha-v2");
expect(loadRecaptchaV2).toHaveBeenCalledWith({ siteKey: "rc-key", container });
});

test("loads recaptcha v3 with siteKey only (no container)", async () => {
const container = document.createElement("div");
await loadBotProtectionWidget({ type: "recaptcha_v3", siteKey: "rc3-key" }, container);

const { loadRecaptchaV3 } = await import("./recaptcha-v3");
expect(loadRecaptchaV3).toHaveBeenCalledWith({ siteKey: "rc3-key" });
});

test("returns the widget from the loader", async () => {
const container = document.createElement("div");
const widget = await loadBotProtectionWidget(
{ type: "turnstile", siteKey: "0x-key" },
container,
);

expect(widget).toBe(mockWidget);
});

test("throws on unknown protection type", async () => {
const container = document.createElement("div");
await expect(
loadBotProtectionWidget({ type: "unknown" as any, siteKey: "key" }, container),
).rejects.toThrow('Unknown bot protection type: "unknown"');
});
});

describe("runTokenLoop", () => {
test("calls onToken with initial token from getToken", async () => {
const onToken = vi.fn();
let resolveToken!: (token: string) => void;

const widget = {
getToken: vi
.fn()
.mockReturnValueOnce(
new Promise<string>((r) => {
resolveToken = r;
}),
)
.mockReturnValue(new Promise(() => {})),
reset: vi.fn(),
remove: vi.fn(),
};

runTokenLoop(widget, onToken);
resolveToken("token-1");

await vi.waitFor(() => expect(onToken).toHaveBeenCalledWith("token-1"));
});

test("calls onToken again when a subsequent getToken resolves", async () => {
const onToken = vi.fn();
let resolveFirst!: (token: string) => void;
let resolveSecond!: (token: string) => void;

const widget = {
getToken: vi
.fn()
.mockReturnValueOnce(
new Promise<string>((r) => {
resolveFirst = r;
}),
)
.mockReturnValueOnce(
new Promise<string>((r) => {
resolveSecond = r;
}),
)
.mockReturnValue(new Promise(() => {})),
reset: vi.fn(),
remove: vi.fn(),
};

runTokenLoop(widget, onToken);

resolveFirst("token-1");
await vi.waitFor(() => expect(onToken).toHaveBeenCalledWith("token-1"));

resolveSecond("token-2");
await vi.waitFor(() => expect(onToken).toHaveBeenCalledWith("token-2"));
expect(onToken).toHaveBeenCalledTimes(2);
});

test("stop breaks the loop and removes widget", async () => {
const onToken = vi.fn();
const widget = {
getToken: vi.fn().mockReturnValue(new Promise(() => {})),
reset: vi.fn(),
remove: vi.fn(),
};

const handle = runTokenLoop(widget, onToken);
handle.stop();

await vi.waitFor(() => expect(widget.remove).toHaveBeenCalled());
expect(onToken).not.toHaveBeenCalled();
});

test("does not call onToken after stop", async () => {
const onToken = vi.fn();
let resolveToken!: (token: string) => void;

const widget = {
getToken: vi
.fn()
.mockReturnValueOnce(
new Promise<string>((r) => {
resolveToken = r;
}),
)
.mockReturnValue(new Promise(() => {})),
reset: vi.fn(),
remove: vi.fn(),
};

const handle = runTokenLoop(widget, onToken);
resolveToken("token-1");
await vi.waitFor(() => expect(onToken).toHaveBeenCalledWith("token-1"));

handle.stop();
expect(onToken).toHaveBeenCalledTimes(1);
});

test("loop exits when getToken rejects", async () => {
const onToken = vi.fn();
const widget = {
getToken: vi.fn().mockRejectedValue(new Error("widget error")),
reset: vi.fn(),
remove: vi.fn(),
};

runTokenLoop(widget, onToken);

await vi.waitFor(() => expect(widget.getToken).toHaveBeenCalled());
expect(onToken).not.toHaveBeenCalled();
});

test("calls onError with widget error when getToken rejects", async () => {
const onToken = vi.fn();
const onError = vi.fn();
const widgetError = new Error("challenge failed");
const widget = {
getToken: vi.fn().mockRejectedValue(widgetError),
reset: vi.fn(),
remove: vi.fn(),
};

runTokenLoop(widget, onToken, onError);

await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(widgetError));
expect(onToken).not.toHaveBeenCalled();
});

test("does not call onError when stop is called", async () => {
const onToken = vi.fn();
const onError = vi.fn();
const widget = {
getToken: vi.fn().mockReturnValue(new Promise(() => {})),
reset: vi.fn(),
remove: vi.fn(),
};

const handle = runTokenLoop(widget, onToken, onError);
handle.stop();

await vi.waitFor(() => expect(widget.remove).toHaveBeenCalled());
expect(onError).not.toHaveBeenCalled();
});
});
72 changes: 72 additions & 0 deletions packages/core/src/bot-protection/auto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { BotProtection } from "../types";
import type { BotProtectionWidget } from "./types";

export type { BotProtectionWidget };

export function runTokenLoop(
widget: BotProtectionWidget,
onToken: (token: string) => void,
onError?: (error: unknown) => void,
): { stop: () => void } {
let stopped = false;
let rejectAbort: ((error: Error) => void) | null = null;

const abortPromise = new Promise<never>((_, reject) => {
rejectAbort = reject;
});

async function loop() {
while (!stopped) {
try {
const token = await Promise.race([widget.getToken(), abortPromise]);
if (!stopped) {
onToken(token);
}
} catch (error) {
if (!stopped) {
onError?.(error);
}
break;
}
}
}

loop().catch((error) => {
if (!stopped) {
onError?.(error);
}
});

return {
stop() {
stopped = true;
if (rejectAbort) {
rejectAbort(new Error("Token loop stopped"));
rejectAbort = null;
}
widget.remove();
},
};
}

export async function loadBotProtectionWidget(
config: BotProtection,
container: HTMLElement,
): Promise<BotProtectionWidget> {
switch (config.type) {
case "turnstile": {
const { loadTurnstile } = await import("./turnstile");
return loadTurnstile({ siteKey: config.siteKey, container });
}
case "recaptcha_v2": {
const { loadRecaptchaV2 } = await import("./recaptcha-v2");
return loadRecaptchaV2({ siteKey: config.siteKey, container });
}
case "recaptcha_v3": {
const { loadRecaptchaV3 } = await import("./recaptcha-v3");
return loadRecaptchaV3({ siteKey: config.siteKey });
}
default:
throw new Error(`Unknown bot protection type: "${(config as BotProtection).type}"`);
}
}
15 changes: 15 additions & 0 deletions packages/core/src/bot-protection/create-widget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,19 @@ describe("createCallbackWidget", () => {
// Should not throw when no one is waiting
expect(() => callbacks.onError(new Error("no listener"))).not.toThrow();
});

test("getToken consumes cached token so next call waits", async () => {
const { widget, callbacks } = createWidget();

callbacks.onToken("first-token");

// First call returns and consumes
expect(await widget.getToken()).toBe("first-token");

// Second call waits for a new token
const promise = widget.getToken();
callbacks.onToken("second-token");

expect(await promise).toBe("second-token");
});
});
7 changes: 6 additions & 1 deletion packages/core/src/bot-protection/create-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function createCallbackWidget(config: {
onToken(token) {
currentToken = token;
if (resolveToken) {
currentToken = null;
resolveToken(token);
resolveToken = null;
rejectToken = null;
Expand All @@ -42,7 +43,11 @@ export function createCallbackWidget(config: {

return {
getToken() {
if (currentToken) return Promise.resolve(currentToken);
if (currentToken) {
const token = currentToken;
currentToken = null;
return Promise.resolve(token);
}
return new Promise((resolve, reject) => {
resolveToken = resolve;
rejectToken = reject;
Expand Down
1 change: 1 addition & 0 deletions packages/core/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default defineConfig({
turnstile: "src/bot-protection/turnstile.ts",
"recaptcha-v2": "src/bot-protection/recaptcha-v2.ts",
"recaptcha-v3": "src/bot-protection/recaptcha-v3.ts",
"bot-protection": "src/bot-protection/auto.ts",
},
dts: true,
format: ["esm", "cjs"],
Expand Down
Loading
Loading