diff --git a/.changeset/automatic-bot-protection.md b/.changeset/automatic-bot-protection.md new file mode 100644 index 0000000..57ec0c6 --- /dev/null +++ b/.changeset/automatic-bot-protection.md @@ -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. diff --git a/packages/core/package.json b/packages/core/package.json index ba60c53..9f7e5c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/bot-protection/auto.test.ts b/packages/core/src/bot-protection/auto.test.ts new file mode 100644 index 0000000..204acdb --- /dev/null +++ b/packages/core/src/bot-protection/auto.test.ts @@ -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((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((r) => { + resolveFirst = r; + }), + ) + .mockReturnValueOnce( + new Promise((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((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(); + }); +}); diff --git a/packages/core/src/bot-protection/auto.ts b/packages/core/src/bot-protection/auto.ts new file mode 100644 index 0000000..700e19f --- /dev/null +++ b/packages/core/src/bot-protection/auto.ts @@ -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((_, 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 { + 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}"`); + } +} diff --git a/packages/core/src/bot-protection/create-widget.test.ts b/packages/core/src/bot-protection/create-widget.test.ts index 26846e5..8c8ee0a 100644 --- a/packages/core/src/bot-protection/create-widget.test.ts +++ b/packages/core/src/bot-protection/create-widget.test.ts @@ -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"); + }); }); diff --git a/packages/core/src/bot-protection/create-widget.ts b/packages/core/src/bot-protection/create-widget.ts index b1c0cd5..84c0883 100644 --- a/packages/core/src/bot-protection/create-widget.ts +++ b/packages/core/src/bot-protection/create-widget.ts @@ -22,6 +22,7 @@ export function createCallbackWidget(config: { onToken(token) { currentToken = token; if (resolveToken) { + currentToken = null; resolveToken(token); resolveToken = null; rejectToken = null; @@ -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; diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 1c65ddb..bac6e28 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -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"], diff --git a/packages/nuxt/src/runtime/composables/useFormRelay.ts b/packages/nuxt/src/runtime/composables/useFormRelay.ts index 6b12d0a..61f558b 100644 --- a/packages/nuxt/src/runtime/composables/useFormRelay.ts +++ b/packages/nuxt/src/runtime/composables/useFormRelay.ts @@ -1,3 +1,4 @@ +import { effectScope, onScopeDispose } from "vue"; import { useFormRelay as useVueFormRelay } from "@formrelay/vue"; import type { UseFormRelayOptions } from "@formrelay/vue"; import { createForm } from "@formrelay/core"; @@ -44,6 +45,12 @@ export async function useFormRelay(options: Partial & { for const publicKey = options.publicKey ?? config.publicKey; + // Create a scope synchronously (while the component instance is active) + // so that watchers inside useVueFormRelay are properly tracked and + // cleaned up on unmount, even though we call it after an await. + const scope = effectScope(); + onScopeDispose(() => scope.stop()); + const schemaClient = import.meta.server && secretKey ? createForm(options.formId, { @@ -56,12 +63,15 @@ export async function useFormRelay(options: Partial & { for schemaClient.getSchema(), ); - return useVueFormRelay({ - formId: options.formId, - publicKey, - initialSchema: initialSchema.value ?? undefined, - validate: options.validate, - onSuccess: options.onSuccess, - onError: options.onError, - }); + return scope.run(() => + useVueFormRelay({ + formId: options.formId, + publicKey, + initialSchema: initialSchema.value ?? undefined, + botProtectionContainer: options.botProtectionContainer, + validate: options.validate, + onSuccess: options.onSuccess, + onError: options.onError, + }), + )!; } diff --git a/packages/vue/src/composables/useFormRelay.test.ts b/packages/vue/src/composables/useFormRelay.test.ts index 0f63bf1..850b05c 100644 --- a/packages/vue/src/composables/useFormRelay.test.ts +++ b/packages/vue/src/composables/useFormRelay.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; -import { nextTick, isRef, isReactive } from "vue"; +import { mount, flushPromises } from "@vue/test-utils"; +import { nextTick, isRef, isReactive, defineComponent, ref } from "vue"; import { ValidationError } from "@formrelay/core"; import { useFormRelay } from "./useFormRelay"; @@ -59,6 +60,21 @@ vi.mock("@formrelay/core", async (importOriginal) => { }; }); +const mockBotWidget = { + getToken: vi.fn(), + reset: vi.fn(), + remove: vi.fn(), +}; + +const mockTokenLoopHandle = { + stop: vi.fn(), +}; + +vi.mock("@formrelay/core/bot-protection", () => ({ + loadBotProtectionWidget: vi.fn().mockResolvedValue(mockBotWidget), + runTokenLoop: vi.fn().mockReturnValue(mockTokenLoopHandle), +})); + beforeEach(() => { vi.clearAllMocks(); mockGetSchema.mockResolvedValue(mockSchema); @@ -66,8 +82,25 @@ beforeEach(() => { success: true, message: "Form submitted successfully.", }); + mockBotWidget.getToken.mockReset(); + mockBotWidget.reset.mockReset(); + mockBotWidget.remove.mockReset(); + mockTokenLoopHandle.stop.mockReset(); }); +function mountComposable(options: Parameters[0]) { + let result!: ReturnType; + const wrapper = mount( + defineComponent({ + setup() { + result = useFormRelay(options); + return () => null; + }, + }), + ); + return { result, wrapper }; +} + describe("useFormRelay", () => { test("returns reactive refs and computed values", () => { const result = useFormRelay({ @@ -148,7 +181,8 @@ describe("useFormRelay", () => { await nextTick(); expect(schemaLoading.value).toBe(false); - expect(schemaError.value).toBe(error); + expect(schemaError.value).toBeInstanceOf(Error); + expect(schemaError.value?.detail).toBe("fetch failed"); }); test("submit sends values to core", async () => { @@ -411,3 +445,259 @@ describe("useFormRelay", () => { ); }); }); + +describe("auto bot protection", () => { + test("loads widget and starts token loop when container and schema are available", async () => { + const container = document.createElement("div"); + const containerRef = ref(container); + + mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + const { loadBotProtectionWidget, runTokenLoop } = + await import("@formrelay/core/bot-protection"); + expect(loadBotProtectionWidget).toHaveBeenCalledWith( + { type: "turnstile", siteKey: "0x-key" }, + container, + ); + expect(runTokenLoop).toHaveBeenCalledWith(mockBotWidget, expect.any(Function)); + }); + + test("does not load widget when container is null", async () => { + const containerRef = ref(null); + + mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + const { loadBotProtectionWidget } = await import("@formrelay/core/bot-protection"); + expect(loadBotProtectionWidget).not.toHaveBeenCalled(); + }); + + test("does not load widget when schema has no bot protection", async () => { + const container = document.createElement("div"); + const containerRef = ref(container); + + mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchema, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + const { loadBotProtectionWidget } = await import("@formrelay/core/bot-protection"); + expect(loadBotProtectionWidget).not.toHaveBeenCalled(); + }); + + test("reset calls widget.reset() when auto bot protection is active", async () => { + const container = document.createElement("div"); + const containerRef = ref(container); + + const { result } = mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + result.reset(); + + expect(mockBotWidget.reset).toHaveBeenCalled(); + }); + + test("cleans up widget on unmount", async () => { + const container = document.createElement("div"); + const containerRef = ref(container); + + const { wrapper } = mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + wrapper.unmount(); + + expect(mockTokenLoopHandle.stop).toHaveBeenCalled(); + }); + + test("reinitializes widget when container ref changes", async () => { + const container1 = document.createElement("div"); + const container2 = document.createElement("div"); + const containerRef = ref(container1); + + mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + const { loadBotProtectionWidget } = await import("@formrelay/core/bot-protection"); + expect(loadBotProtectionWidget).toHaveBeenCalledTimes(1); + + // Simulate v-if: container destroyed and recreated + containerRef.value = null; + await flushPromises(); + + expect(mockTokenLoopHandle.stop).toHaveBeenCalledTimes(1); + + containerRef.value = container2; + await flushPromises(); + + expect(loadBotProtectionWidget).toHaveBeenCalledTimes(2); + expect(loadBotProtectionWidget).toHaveBeenLastCalledWith( + { type: "turnstile", siteKey: "0x-key" }, + container2, + ); + }); + + test("handles widget loading failure gracefully", async () => { + const container = document.createElement("div"); + const containerRef = ref(container); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { loadBotProtectionWidget } = await import("@formrelay/core/bot-protection"); + vi.mocked(loadBotProtectionWidget).mockRejectedValueOnce( + new Error("Script blocked by ad blocker"), + ); + + const { result } = mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + expect(result.canSubmit.value).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + "[FormRelay] Failed to initialize bot protection:", + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + test("without botProtectionContainer, existing behavior is unchanged", async () => { + const { result } = mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + }); + + await flushPromises(); + + const { loadBotProtectionWidget } = await import("@formrelay/core/bot-protection"); + expect(loadBotProtectionWidget).not.toHaveBeenCalled(); + + // Manual flow still works + result.setBotToken("manual-token"); + expect(result.canSubmit.value).toBe(true); + }); + + test("reset during active auto bot protection clears token and resets widget", async () => { + const container = document.createElement("div"); + const containerRef = ref(container); + + const { runTokenLoop } = await import("@formrelay/core/bot-protection"); + // Simulate token loop calling setBotToken + vi.mocked(runTokenLoop).mockImplementation((_widget, onToken) => { + onToken("auto-token"); + return mockTokenLoopHandle; + }); + + const { result } = mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + expect(result.canSubmit.value).toBe(true); + + result.reset(); + + expect(result.canSubmit.value).toBe(false); + expect(mockBotWidget.reset).toHaveBeenCalled(); + }); + + test("reinitializes widget and token loop when container ref changes", async () => { + const container1 = document.createElement("div"); + const container2 = document.createElement("div"); + const containerRef = ref(container1); + + mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + const { loadBotProtectionWidget, runTokenLoop } = + await import("@formrelay/core/bot-protection"); + expect(runTokenLoop).toHaveBeenCalledTimes(1); + + containerRef.value = null; + await flushPromises(); + + containerRef.value = container2; + await flushPromises(); + + expect(loadBotProtectionWidget).toHaveBeenCalledTimes(2); + expect(runTokenLoop).toHaveBeenCalledTimes(2); + }); + + test("clears botToken when container is destroyed via v-if", async () => { + const container = document.createElement("div"); + const containerRef = ref(container); + + const { runTokenLoop } = await import("@formrelay/core/bot-protection"); + vi.mocked(runTokenLoop).mockImplementation((_widget, onToken) => { + onToken("auto-token"); + return mockTokenLoopHandle; + }); + + const { result } = mountComposable({ + formId: "01abc", + publicKey: "pk_fr_test", + initialSchema: mockSchemaWithBot, + botProtectionContainer: containerRef, + }); + + await flushPromises(); + + expect(result.canSubmit.value).toBe(true); + + // Simulate v-if destroying the container + containerRef.value = null; + await flushPromises(); + + expect(result.canSubmit.value).toBe(false); + expect(mockTokenLoopHandle.stop).toHaveBeenCalled(); + }); +}); diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index 01cb78c..84e3f04 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -1,12 +1,7 @@ -import { ref, reactive, computed } from "vue"; -import { createForm, ValidationError } from "@formrelay/core"; -import type { - FormSchema, - FormRelayError, - JsonSchema, - BotProtection, - FormField, -} from "@formrelay/core"; +import { ref, reactive, computed, watch } from "vue"; +import { createForm, FormRelayError, ValidationError } from "@formrelay/core"; +import type { FormSchema, JsonSchema, BotProtection, FormField } from "@formrelay/core"; +import type { BotProtectionWidget } from "@formrelay/core/bot-protection"; import type { UseFormRelayOptions, UseFormRelayReturn } from "../types"; export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { @@ -60,7 +55,15 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { schema.value = loadedSchema; initializeValues(loadedSchema); } catch (error) { - schemaError.value = error as FormRelayError; + schemaError.value = + error instanceof FormRelayError + ? error + : new FormRelayError({ + type: "", + title: "Unexpected error", + status: 0, + detail: error instanceof Error ? error.message : String(error), + }); } finally { schemaLoading.value = false; } @@ -101,6 +104,9 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { } } + let currentWidget: BotProtectionWidget | null = null; + let tokenLoopHandle: { stop: () => void } | null = null; + function reset() { for (const key of Object.keys(values)) { values[key] = ""; @@ -108,12 +114,53 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { errors.value = {}; submitted.value = false; botToken.value = null; + if (currentWidget) { + currentWidget.reset(); + } } function setBotToken(token: string) { botToken.value = token; } + if (options.botProtectionContainer) { + watch( + [options.botProtectionContainer, botProtection] as const, + async ([container, protection], _, onCleanup) => { + let cancelled = false; + onCleanup(() => { + cancelled = true; + tokenLoopHandle?.stop(); + tokenLoopHandle = null; + currentWidget = null; + botToken.value = null; + }); + + if (!container || !protection) return; + + try { + const { loadBotProtectionWidget, runTokenLoop } = + await import("@formrelay/core/bot-protection"); + if (cancelled) return; + + const widget = await loadBotProtectionWidget(protection, container); + if (cancelled) { + widget.remove(); + return; + } + + currentWidget = widget; + tokenLoopHandle = runTokenLoop(widget, setBotToken); + } catch (error) { + if (!cancelled) { + console.error("[FormRelay] Failed to initialize bot protection:", error); + } + } + }, + { immediate: true }, + ); + } + return { schema, fields, diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 80e8ce7..95e922c 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -11,6 +11,7 @@ export interface UseFormRelayOptions { formId: string; publicKey: string; initialSchema?: FormSchema; + botProtectionContainer?: Ref; validate?: (data: Record, schema: JsonSchema) => Record; onSuccess?: (result: { message: string }) => void; onError?: (error: FormRelayError) => void; diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts index 362e30d..927bfbb 100644 --- a/packages/vue/vite.config.ts +++ b/packages/vue/vite.config.ts @@ -19,9 +19,10 @@ export default defineConfig({ return pkg; }, }, - external: ["vue", "@formrelay/core"], + external: ["vue", /^@formrelay\/core/], }, test: { include: ["src/**/*.test.ts"], + environment: "jsdom", }, });