From fa092462a5c9aa64ddbf606afcefdce7975e84b6 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:46:22 +0200 Subject: [PATCH 01/17] feat(core): make getToken() consuming for token loop support Clear currentToken after returning it so subsequent calls wait for the next token, preventing an infinite spin in the upcoming runTokenLoop. --- .../core/src/bot-protection/create-widget.test.ts | 15 +++++++++++++++ packages/core/src/bot-protection/create-widget.ts | 6 +++++- 2 files changed, 20 insertions(+), 1 deletion(-) 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..4f2afa5 100644 --- a/packages/core/src/bot-protection/create-widget.ts +++ b/packages/core/src/bot-protection/create-widget.ts @@ -42,7 +42,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; From fdccac60fea6390f479b34734980676fb781043f Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:50:41 +0200 Subject: [PATCH 02/17] fix(core): clear currentToken when resolving pending getToken promise --- packages/core/src/bot-protection/create-widget.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/bot-protection/create-widget.ts b/packages/core/src/bot-protection/create-widget.ts index 4f2afa5..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; From b21ae8108759635a2390a7e1675915df87e5a649 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:52:23 +0200 Subject: [PATCH 03/17] feat(core): add loadBotProtectionWidget for dynamic widget loading --- packages/core/src/bot-protection/auto.test.ts | 68 +++++++++++++++++++ packages/core/src/bot-protection/auto.ts | 24 +++++++ 2 files changed, 92 insertions(+) create mode 100644 packages/core/src/bot-protection/auto.test.ts create mode 100644 packages/core/src/bot-protection/auto.ts 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..60e3701 --- /dev/null +++ b/packages/core/src/bot-protection/auto.test.ts @@ -0,0 +1,68 @@ +/** + * @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 } 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"'); + }); +}); diff --git a/packages/core/src/bot-protection/auto.ts b/packages/core/src/bot-protection/auto.ts new file mode 100644 index 0000000..f6f0a57 --- /dev/null +++ b/packages/core/src/bot-protection/auto.ts @@ -0,0 +1,24 @@ +import type { BotProtection } from "../types"; +import type { BotProtectionWidget } from "./types"; + +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}"`); + } +} From 070a63926b7c2c361710a624888d47ccc94e6a2c Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:54:13 +0200 Subject: [PATCH 04/17] feat(core): add runTokenLoop for automatic token acquisition --- packages/core/src/bot-protection/auto.test.ts | 95 ++++++++++++++++++- packages/core/src/bot-protection/auto.ts | 39 ++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/packages/core/src/bot-protection/auto.test.ts b/packages/core/src/bot-protection/auto.test.ts index 60e3701..3519ef1 100644 --- a/packages/core/src/bot-protection/auto.test.ts +++ b/packages/core/src/bot-protection/auto.test.ts @@ -21,7 +21,7 @@ vi.mock("./recaptcha-v3", () => ({ loadRecaptchaV3: vi.fn().mockResolvedValue(mockWidget), })); -import { loadBotProtectionWidget } from "./auto"; +import { loadBotProtectionWidget, runTokenLoop } from "./auto"; beforeEach(() => { vi.clearAllMocks(); @@ -66,3 +66,96 @@ describe("loadBotProtectionWidget", () => { ).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(); + }); +}); diff --git a/packages/core/src/bot-protection/auto.ts b/packages/core/src/bot-protection/auto.ts index f6f0a57..2972820 100644 --- a/packages/core/src/bot-protection/auto.ts +++ b/packages/core/src/bot-protection/auto.ts @@ -1,6 +1,45 @@ import type { BotProtection } from "../types"; import type { BotProtectionWidget } from "./types"; +export function runTokenLoop( + widget: BotProtectionWidget, + onToken: (token: string) => void, +): { stop: () => void } { + let stopped = false; + let rejectAbort: ((error: Error) => void) | null = null; + + async function loop() { + while (!stopped) { + try { + const token = await Promise.race([ + widget.getToken(), + new Promise((_, reject) => { + rejectAbort = reject; + }), + ]); + if (!stopped) { + onToken(token); + } + } catch { + break; + } + } + } + + loop(); + + return { + stop() { + stopped = true; + if (rejectAbort) { + rejectAbort(new Error("Token loop stopped")); + rejectAbort = null; + } + widget.remove(); + }, + }; +} + export async function loadBotProtectionWidget( config: BotProtection, container: HTMLElement, From 8d3da5627aa0a473e721935cac4461b6bb513b3c Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:55:46 +0200 Subject: [PATCH 05/17] feat(core): add bot-protection entrypoint --- packages/core/package.json | 5 +++++ packages/core/vite.config.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/core/package.json b/packages/core/package.json index 2021884..044be87 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,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/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"], From 5539492038a2df0f0bc3af2e60dc8ff21697f462 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:55:50 +0200 Subject: [PATCH 06/17] feat(vue): add botProtectionContainer option type --- packages/vue/src/types.ts | 1 + 1 file changed, 1 insertion(+) 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; From 49ab2f38274ea84c4e8f7c4807897eef4ae6973d Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:58:36 +0200 Subject: [PATCH 07/17] feat(vue): add automatic bot protection in useFormRelay --- .../vue/src/composables/useFormRelay.test.ts | 177 +++++++++++++++++- packages/vue/src/composables/useFormRelay.ts | 42 ++++- packages/vue/vite.config.ts | 1 + 3 files changed, 218 insertions(+), 2 deletions(-) diff --git a/packages/vue/src/composables/useFormRelay.test.ts b/packages/vue/src/composables/useFormRelay.test.ts index 0f63bf1..928d705 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({ @@ -411,3 +444,145 @@ 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("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); + }); +}); diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index 01cb78c..8d991de 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -1,4 +1,4 @@ -import { ref, reactive, computed } from "vue"; +import { ref, reactive, computed, watch } from "vue"; import { createForm, ValidationError } from "@formrelay/core"; import type { FormSchema, @@ -7,6 +7,7 @@ import type { BotProtection, FormField, } from "@formrelay/core"; +import type { BotProtectionWidget } from "@formrelay/core/turnstile"; import type { UseFormRelayOptions, UseFormRelayReturn } from "../types"; export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { @@ -101,6 +102,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 +112,48 @@ 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) => { + if (!container || !protection) return; + + let cancelled = false; + onCleanup(() => { + cancelled = true; + tokenLoopHandle?.stop(); + tokenLoopHandle = null; + currentWidget = null; + botToken.value = null; + }); + + 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); + }, + { immediate: true }, + ); + } + return { schema, fields, diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts index 362e30d..4b2892d 100644 --- a/packages/vue/vite.config.ts +++ b/packages/vue/vite.config.ts @@ -23,5 +23,6 @@ export default defineConfig({ }, test: { include: ["src/**/*.test.ts"], + environment: "jsdom", }, }); From fd9e1c7498d2367dbd6b1be064cfb07051f1edce Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:00:36 +0200 Subject: [PATCH 08/17] build(vue): externalize @formrelay/core subpath imports --- packages/vue/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts index 4b2892d..927bfbb 100644 --- a/packages/vue/vite.config.ts +++ b/packages/vue/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ return pkg; }, }, - external: ["vue", "@formrelay/core"], + external: ["vue", /^@formrelay\/core/], }, test: { include: ["src/**/*.test.ts"], From 19eebfc5919b906a31f278b302b6d668bd033c1d Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:00:59 +0200 Subject: [PATCH 09/17] feat(nuxt): pass botProtectionContainer through to Vue composable --- packages/nuxt/src/runtime/composables/useFormRelay.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nuxt/src/runtime/composables/useFormRelay.ts b/packages/nuxt/src/runtime/composables/useFormRelay.ts index 6b12d0a..67f13c7 100644 --- a/packages/nuxt/src/runtime/composables/useFormRelay.ts +++ b/packages/nuxt/src/runtime/composables/useFormRelay.ts @@ -60,6 +60,7 @@ export async function useFormRelay(options: Partial & { for formId: options.formId, publicKey, initialSchema: initialSchema.value ?? undefined, + botProtectionContainer: options.botProtectionContainer, validate: options.validate, onSuccess: options.onSuccess, onError: options.onError, From 22b536c8b6146eca428f5b27b6aa72eef563d90c Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:02:36 +0200 Subject: [PATCH 10/17] chore: add changeset for automatic bot protection --- .changeset/automatic-bot-protection.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/automatic-bot-protection.md 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. From 15da7dcdebd3c58cc6a479c2601508155cae4d97 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:11:56 +0200 Subject: [PATCH 11/17] fix: address PR review findings - Fix onCleanup registered after early-return guard (widget leak on v-if cycle) - Fix rejectAbort overwritten each loop iteration (promise leak) - Add try/catch in async watch for widget loading failures - Add onError callback to runTokenLoop to distinguish abort from real errors - Import BotProtectionWidget from @formrelay/core/bot-protection (correct entrypoint) - Wrap Nuxt composable in effectScope for proper watcher cleanup after await - Add tests for onError callback and widget loading failure --- packages/core/src/bot-protection/auto.test.ts | 32 +++++++++++++++++ packages/core/src/bot-protection/auto.ts | 19 +++++++---- .../src/runtime/composables/useFormRelay.ts | 27 ++++++++++----- .../vue/src/composables/useFormRelay.test.ts | 26 ++++++++++++++ packages/vue/src/composables/useFormRelay.ts | 34 +++++++++++-------- 5 files changed, 108 insertions(+), 30 deletions(-) diff --git a/packages/core/src/bot-protection/auto.test.ts b/packages/core/src/bot-protection/auto.test.ts index 3519ef1..4e1e74c 100644 --- a/packages/core/src/bot-protection/auto.test.ts +++ b/packages/core/src/bot-protection/auto.test.ts @@ -158,4 +158,36 @@ describe("runTokenLoop", () => { 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 index 2972820..8742649 100644 --- a/packages/core/src/bot-protection/auto.ts +++ b/packages/core/src/bot-protection/auto.ts @@ -1,26 +1,31 @@ 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(), - new Promise((_, reject) => { - rejectAbort = reject; - }), - ]); + const token = await Promise.race([widget.getToken(), abortPromise]); if (!stopped) { onToken(token); } - } catch { + } catch (error) { + if (!stopped) { + onError?.(error); + } break; } } diff --git a/packages/nuxt/src/runtime/composables/useFormRelay.ts b/packages/nuxt/src/runtime/composables/useFormRelay.ts index 67f13c7..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,13 +63,15 @@ export async function useFormRelay(options: Partial & { for schemaClient.getSchema(), ); - return useVueFormRelay({ - formId: options.formId, - publicKey, - initialSchema: initialSchema.value ?? undefined, - botProtectionContainer: options.botProtectionContainer, - 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 928d705..fb4f1c1 100644 --- a/packages/vue/src/composables/useFormRelay.test.ts +++ b/packages/vue/src/composables/useFormRelay.test.ts @@ -569,6 +569,32 @@ describe("auto bot protection", () => { ); }); + 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", diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index 8d991de..679d0d0 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -7,7 +7,7 @@ import type { BotProtection, FormField, } from "@formrelay/core"; -import type { BotProtectionWidget } from "@formrelay/core/turnstile"; +import type { BotProtectionWidget } from "@formrelay/core/bot-protection"; import type { UseFormRelayOptions, UseFormRelayReturn } from "../types"; export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { @@ -125,8 +125,6 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { watch( [options.botProtectionContainer, botProtection] as const, async ([container, protection], _, onCleanup) => { - if (!container || !protection) return; - let cancelled = false; onCleanup(() => { cancelled = true; @@ -136,19 +134,27 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { botToken.value = null; }); - const { loadBotProtectionWidget, runTokenLoop } = await import( - "@formrelay/core/bot-protection" - ); - if (cancelled) return; + if (!container || !protection) return; - const widget = await loadBotProtectionWidget(protection, container); - if (cancelled) { - widget.remove(); - 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); + } } - - currentWidget = widget; - tokenLoopHandle = runTokenLoop(widget, setBotToken); }, { immediate: true }, ); From 4cddb9e8d02ed46fedaf5510a5aba6f1b6433987 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:15:14 +0200 Subject: [PATCH 12/17] fix: add .catch() safety net, fix schemaError cast, add edge case tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .catch() to fire-and-forget loop() call in runTokenLoop - Fix unsafe error cast in fetchSchema — wrap non-FormRelayError in FormRelayError - Add edge case tests: reset during active bot protection, runTokenLoop called twice on container reinit, botToken cleared on v-if container destroy --- packages/core/src/bot-protection/auto.ts | 6 +- .../vue/src/composables/useFormRelay.test.ts | 87 ++++++++++++++++++- packages/vue/src/composables/useFormRelay.ts | 13 ++- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/core/src/bot-protection/auto.ts b/packages/core/src/bot-protection/auto.ts index 8742649..700e19f 100644 --- a/packages/core/src/bot-protection/auto.ts +++ b/packages/core/src/bot-protection/auto.ts @@ -31,7 +31,11 @@ export function runTokenLoop( } } - loop(); + loop().catch((error) => { + if (!stopped) { + onError?.(error); + } + }); return { stop() { diff --git a/packages/vue/src/composables/useFormRelay.test.ts b/packages/vue/src/composables/useFormRelay.test.ts index fb4f1c1..508bf83 100644 --- a/packages/vue/src/composables/useFormRelay.test.ts +++ b/packages/vue/src/composables/useFormRelay.test.ts @@ -181,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 () => { @@ -611,4 +612,88 @@ describe("auto bot protection", () => { 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 679d0d0..19ca926 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -1,8 +1,7 @@ import { ref, reactive, computed, watch } from "vue"; -import { createForm, ValidationError } from "@formrelay/core"; +import { createForm, FormRelayError, ValidationError } from "@formrelay/core"; import type { FormSchema, - FormRelayError, JsonSchema, BotProtection, FormField, @@ -61,7 +60,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; } From c3f27da069d2eca21e9e7933d44f847e8cd07633 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:19:40 +0200 Subject: [PATCH 13/17] style: fix formatting --- packages/core/src/bot-protection/auto.test.ts | 38 +++++++++++++++---- .../vue/src/composables/useFormRelay.test.ts | 10 +++-- packages/vue/src/composables/useFormRelay.ts | 12 ++---- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/core/src/bot-protection/auto.test.ts b/packages/core/src/bot-protection/auto.test.ts index 4e1e74c..204acdb 100644 --- a/packages/core/src/bot-protection/auto.test.ts +++ b/packages/core/src/bot-protection/auto.test.ts @@ -54,7 +54,10 @@ describe("loadBotProtectionWidget", () => { test("returns the widget from the loader", async () => { const container = document.createElement("div"); - const widget = await loadBotProtectionWidget({ type: "turnstile", siteKey: "0x-key" }, container); + const widget = await loadBotProtectionWidget( + { type: "turnstile", siteKey: "0x-key" }, + container, + ); expect(widget).toBe(mockWidget); }); @@ -73,8 +76,13 @@ describe("runTokenLoop", () => { let resolveToken!: (token: string) => void; const widget = { - getToken: vi.fn() - .mockReturnValueOnce(new Promise((r) => { resolveToken = r; })) + getToken: vi + .fn() + .mockReturnValueOnce( + new Promise((r) => { + resolveToken = r; + }), + ) .mockReturnValue(new Promise(() => {})), reset: vi.fn(), remove: vi.fn(), @@ -92,9 +100,18 @@ describe("runTokenLoop", () => { let resolveSecond!: (token: string) => void; const widget = { - getToken: vi.fn() - .mockReturnValueOnce(new Promise((r) => { resolveFirst = r; })) - .mockReturnValueOnce(new Promise((r) => { resolveSecond = r; })) + getToken: vi + .fn() + .mockReturnValueOnce( + new Promise((r) => { + resolveFirst = r; + }), + ) + .mockReturnValueOnce( + new Promise((r) => { + resolveSecond = r; + }), + ) .mockReturnValue(new Promise(() => {})), reset: vi.fn(), remove: vi.fn(), @@ -130,8 +147,13 @@ describe("runTokenLoop", () => { let resolveToken!: (token: string) => void; const widget = { - getToken: vi.fn() - .mockReturnValueOnce(new Promise((r) => { resolveToken = r; })) + getToken: vi + .fn() + .mockReturnValueOnce( + new Promise((r) => { + resolveToken = r; + }), + ) .mockReturnValue(new Promise(() => {})), reset: vi.fn(), remove: vi.fn(), diff --git a/packages/vue/src/composables/useFormRelay.test.ts b/packages/vue/src/composables/useFormRelay.test.ts index 508bf83..850b05c 100644 --- a/packages/vue/src/composables/useFormRelay.test.ts +++ b/packages/vue/src/composables/useFormRelay.test.ts @@ -460,7 +460,8 @@ describe("auto bot protection", () => { await flushPromises(); - const { loadBotProtectionWidget, runTokenLoop } = await import("@formrelay/core/bot-protection"); + const { loadBotProtectionWidget, runTokenLoop } = + await import("@formrelay/core/bot-protection"); expect(loadBotProtectionWidget).toHaveBeenCalledWith( { type: "turnstile", siteKey: "0x-key" }, container, @@ -576,7 +577,9 @@ describe("auto bot protection", () => { 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")); + vi.mocked(loadBotProtectionWidget).mockRejectedValueOnce( + new Error("Script blocked by ad blocker"), + ); const { result } = mountComposable({ formId: "01abc", @@ -655,7 +658,8 @@ describe("auto bot protection", () => { await flushPromises(); - const { loadBotProtectionWidget, runTokenLoop } = await import("@formrelay/core/bot-protection"); + const { loadBotProtectionWidget, runTokenLoop } = + await import("@formrelay/core/bot-protection"); expect(runTokenLoop).toHaveBeenCalledTimes(1); containerRef.value = null; diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index 19ca926..84e3f04 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -1,11 +1,6 @@ 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 { FormSchema, JsonSchema, BotProtection, FormField } from "@formrelay/core"; import type { BotProtectionWidget } from "@formrelay/core/bot-protection"; import type { UseFormRelayOptions, UseFormRelayReturn } from "../types"; @@ -144,9 +139,8 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { if (!container || !protection) return; try { - const { loadBotProtectionWidget, runTokenLoop } = await import( - "@formrelay/core/bot-protection" - ); + const { loadBotProtectionWidget, runTokenLoop } = + await import("@formrelay/core/bot-protection"); if (cancelled) return; const widget = await loadBotProtectionWidget(protection, container); From 3442e8726e5f9a565b1d54bdaa153eafda69d257 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:21:22 +0200 Subject: [PATCH 14/17] style: fix pre-existing formatting issues --- README.md | 12 ++++++------ packages/core/README.md | 14 +++++++------- packages/core/package.json | 4 ++-- packages/nuxt/README.md | 5 +++-- packages/nuxt/package.json | 4 ++-- packages/react/package.json | 6 +++--- packages/vue/README.md | 9 ++++----- packages/vue/package.json | 4 ++-- 8 files changed, 29 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 93027da..3316956 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ Official JavaScript/TypeScript SDK packages for [FormRelay](https://formrelay.ap ## Packages -| Package | Description | -|---------|-------------| -| [`@formrelay/core`](./packages/core) | Framework-agnostic core client | -| [`@formrelay/vue`](./packages/vue) | Vue 3 composable + renderless component | -| [`@formrelay/nuxt`](./packages/nuxt) | Nuxt module with auto-imports and SSR | -| `@formrelay/react` | React hooks (coming soon) | +| Package | Description | +| ------------------------------------ | --------------------------------------- | +| [`@formrelay/core`](./packages/core) | Framework-agnostic core client | +| [`@formrelay/vue`](./packages/vue) | Vue 3 composable + renderless component | +| [`@formrelay/nuxt`](./packages/nuxt) | Nuxt module with auto-imports and SSR | +| `@formrelay/react` | React hooks (coming soon) | ## Documentation diff --git a/packages/core/README.md b/packages/core/README.md index 8e67155..89c9b3b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -27,13 +27,13 @@ if (result.success) { ## Entrypoints -| Import | Description | -|--------|-------------| -| `@formrelay/core` | Main client — schema fetching, submission, error types | -| `@formrelay/core/validation` | Optional JSON Schema 2020-12 validation | -| `@formrelay/core/turnstile` | Cloudflare Turnstile widget loader | -| `@formrelay/core/recaptcha-v2` | Google reCAPTCHA v2 widget loader | -| `@formrelay/core/recaptcha-v3` | Google reCAPTCHA v3 widget loader | +| Import | Description | +| ------------------------------ | ------------------------------------------------------ | +| `@formrelay/core` | Main client — schema fetching, submission, error types | +| `@formrelay/core/validation` | Optional JSON Schema 2020-12 validation | +| `@formrelay/core/turnstile` | Cloudflare Turnstile widget loader | +| `@formrelay/core/recaptcha-v2` | Google reCAPTCHA v2 widget loader | +| `@formrelay/core/recaptcha-v3` | Google reCAPTCHA v3 widget loader | All entrypoints are tree-shakeable — unused code is never bundled. diff --git a/packages/core/package.json b/packages/core/package.json index 044be87..9f7e5c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,7 @@ { "name": "@formrelay/core", "version": "0.1.1", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/FormRelay/js.git", @@ -58,6 +59,5 @@ "jsdom": "^29.0.1", "typescript": "latest", "vite-plus": "latest" - }, - "license": "MIT" + } } diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index 08cb4db..709d112 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -26,8 +26,9 @@ export default defineNuxtConfig({ ```vue