From 2c742c5ad76560dff2b5dca259e1a167cfc57d17 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:38:35 +0200 Subject: [PATCH 1/6] feat(core): allow submitForm to accept SubmitConfig instead of full schema submitForm now accepts FormSchema | SubmitConfig as its second argument. SubmitConfig only requires submitUrl, with optional honeypotField and botProtection. This enables submission without fetching the schema. --- packages/core/src/index.ts | 2 +- packages/core/src/submit.test.ts | 53 ++++++++++++++++++++++++++++++++ packages/core/src/submit.ts | 31 +++++++++++++------ 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c95c4c1..79267c7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,7 +8,7 @@ export type { JsonSchema, SubmitOptions, } from "./types"; -export type { SubmitResult } from "./submit"; +export type { SubmitConfig, SubmitResult } from "./submit"; export type { ErrorOptions } from "./errors"; export { BotProtectionError, diff --git a/packages/core/src/submit.test.ts b/packages/core/src/submit.test.ts index 6648935..6d4e4ec 100644 --- a/packages/core/src/submit.test.ts +++ b/packages/core/src/submit.test.ts @@ -286,4 +286,57 @@ describe("submitForm", () => { submitForm({ email: "john@example.com" }, schema, client, { botToken: "token" }), ).rejects.toThrow('Unknown bot protection type "unknown_type"'); }); + + test("submits to explicit URL without a schema", async () => { + const client = createMockHttpClient({ + status: 200, + headers: { get: () => null }, + json: () => Promise.resolve({ message: "Form submitted successfully." }), + }); + + const result = await submitForm( + { email: "john@example.com" }, + { submitUrl: "https://formrelay.app/api/v1/form/01abc" }, + client, + ); + + expect(client.post).toHaveBeenCalledWith( + "https://formrelay.app/api/v1/form/01abc", + { email: "john@example.com" }, + { headers: {} }, + ); + expect(result).toEqual({ + success: true, + message: "Form submitted successfully.", + }); + }); + + test("submits without schema using honeypotField and botProtection options", async () => { + const client = createMockHttpClient({ + status: 200, + headers: { get: () => null }, + json: () => Promise.resolve({ message: "OK" }), + }); + + await submitForm( + { email: "john@example.com" }, + { + submitUrl: "https://formrelay.app/api/v1/form/01abc", + honeypotField: "_hp_phone", + botProtection: { type: "turnstile", siteKey: "0x-key" }, + }, + client, + { botToken: "turnstile-token" }, + ); + + expect(client.post).toHaveBeenCalledWith( + "https://formrelay.app/api/v1/form/01abc", + { + email: "john@example.com", + _hp_phone: "", + "cf-turnstile-response": "turnstile-token", + }, + { headers: {} }, + ); + }); }); diff --git a/packages/core/src/submit.ts b/packages/core/src/submit.ts index 9523ab5..49a7dc6 100644 --- a/packages/core/src/submit.ts +++ b/packages/core/src/submit.ts @@ -1,6 +1,6 @@ import type { HttpAdapter } from "./http/types"; import { parseJsonSafe } from "./http/parse-json"; -import type { FormSchema, SubmitOptions } from "./types"; +import type { BotProtection, FormSchema, SubmitOptions } from "./types"; import { FormRelayError, parseErrorResponse } from "./errors"; const BOT_TOKEN_FIELDS: Record = { @@ -13,15 +13,25 @@ export type SubmitResult = | { success: true; message: string } | { success: false; error: FormRelayError }; +export interface SubmitConfig { + submitUrl: string; + honeypotField?: string | null; + botProtection?: BotProtection | null; +} + export async function submitForm( data: Record, - schema: FormSchema, + schemaOrConfig: FormSchema | SubmitConfig, httpClient: HttpAdapter, options?: SubmitOptions, ): Promise { - const body = buildRequestBody(data, schema, options); + const submitUrl = schemaOrConfig.submitUrl; + const honeypotField = schemaOrConfig.honeypotField ?? null; + const botProtection = schemaOrConfig.botProtection ?? null; + + const body = buildRequestBody(data, honeypotField, botProtection, options); - const response = await httpClient.post(schema.submitUrl, body, { + const response = await httpClient.post(submitUrl, body, { headers: {}, }); @@ -54,23 +64,24 @@ export async function submitForm( function buildRequestBody( data: Record, - schema: FormSchema, + honeypotField: string | null, + botProtection: BotProtection | null, options?: SubmitOptions, ): Record { const body: Record = { ...data }; - if (schema.honeypotField) { - body[schema.honeypotField] = ""; + if (honeypotField) { + body[honeypotField] = ""; } - if (!options?.botToken || !schema.botProtection) { + if (!options?.botToken || !botProtection) { return body; } - const tokenField = BOT_TOKEN_FIELDS[schema.botProtection.type]; + const tokenField = BOT_TOKEN_FIELDS[botProtection.type]; if (!tokenField) { throw new Error( - `Unknown bot protection type "${schema.botProtection.type}". ` + + `Unknown bot protection type "${botProtection.type}". ` + `Supported types: ${Object.keys(BOT_TOKEN_FIELDS).join(", ")}`, ); } From 9e96a95720deb3031c6f3fa663cb90405890c435 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:42:05 +0200 Subject: [PATCH 2/6] feat(core): support schema-less submission in createForm Make publicKey optional on FormClientOptions; when absent, submit() constructs a SubmitConfig from formId + API_BASE_URL plus the new botProtection/honeypotField options instead of auto-fetching the schema. Cached schema still takes precedence when available. --- packages/core/src/client.test.ts | 61 ++++++++++++++++++++++++++++++++ packages/core/src/client.ts | 32 +++++++++++++---- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 9b705ef..252f27e 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -122,4 +122,65 @@ describe("createForm", () => { expect.any(Object), ); }); + + test("submit without publicKey uses constructed URL", async () => { + const client = createMockHttpClient(); + const form = createForm("01abc", { + httpClient: client, + }); + + await form.submit({ email: "test@example.com" }); + + expect(client.get).not.toHaveBeenCalled(); + expect(client.post).toHaveBeenCalledWith( + "https://formrelay.app/api/v1/form/01abc", + { email: "test@example.com" }, + { headers: {} }, + ); + }); + + test("submit without schema uses botProtection and honeypotField options", async () => { + const client = createMockHttpClient(); + const form = createForm("01abc", { + httpClient: client, + botProtection: { type: "turnstile", siteKey: "0x-key" }, + honeypotField: "_hp_phone", + }); + + await form.submit({ email: "test@example.com" }, { botToken: "token-123" }); + + expect(client.get).not.toHaveBeenCalled(); + expect(client.post).toHaveBeenCalledWith( + "https://formrelay.app/api/v1/form/01abc", + { + email: "test@example.com", + _hp_phone: "", + "cf-turnstile-response": "token-123", + }, + { headers: {} }, + ); + }); + + test("schema values take precedence over options", async () => { + const client = createMockHttpClient(); + const form = createForm("01abc", { + publicKey: "pk_fr_test", + httpClient: client, + honeypotField: "_hp_custom", + }); + + await form.getSchema(); + await form.submit({ email: "test@example.com" }); + + // Schema has honeypotField: "_hp_phone", options have "_hp_custom" + // Schema should win + expect(client.post).toHaveBeenCalledWith( + "https://formrelay.app/api/v1/form/01abc", + { + email: "test@example.com", + _hp_phone: "", + }, + expect.any(Object), + ); + }); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 65109d3..4357ae7 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,14 +1,16 @@ import type { HttpAdapter } from "./http/types"; -import type { FormSchema, SubmitOptions } from "./types"; -import type { SubmitResult } from "./submit"; +import type { BotProtection, FormSchema, SubmitOptions } from "./types"; +import type { SubmitConfig, SubmitResult } from "./submit"; import { createFetchAdapter } from "./http/fetch"; import { createSchemaFetcher } from "./schema"; import { submitForm } from "./submit"; import { API_BASE_URL } from "./constants"; export interface FormClientOptions { - publicKey: string; + publicKey?: string; httpClient?: HttpAdapter; + botProtection?: BotProtection; + honeypotField?: string; } export interface FormClient { @@ -16,23 +18,39 @@ export interface FormClient { submit(data: Record, options?: SubmitOptions): Promise; } -export function createForm(formId: string, options: FormClientOptions): FormClient { +export function createForm(formId: string, options: FormClientOptions = {}): FormClient { const httpClient = options.httpClient ?? createFetchAdapter(); - const fetchSchema = createSchemaFetcher(formId, API_BASE_URL, options.publicKey, httpClient); + + const fetchSchema = options.publicKey + ? createSchemaFetcher(formId, API_BASE_URL, options.publicKey, httpClient) + : null; let cachedSchema: FormSchema | null = null; + function getSubmitConfig(): FormSchema | SubmitConfig { + if (cachedSchema) return cachedSchema; + + return { + submitUrl: `${API_BASE_URL}/api/v1/form/${formId}`, + honeypotField: options.honeypotField, + botProtection: options.botProtection, + }; + } + return { async getSchema() { + if (!fetchSchema) { + throw new Error("Cannot fetch schema without a publicKey"); + } cachedSchema = await fetchSchema(); return cachedSchema; }, async submit(data: Record, submitOptions?: SubmitOptions) { - if (!cachedSchema) { + if (!cachedSchema && fetchSchema) { cachedSchema = await fetchSchema(); } - return submitForm(data, cachedSchema, httpClient, submitOptions); + return submitForm(data, getSubmitConfig(), httpClient, submitOptions); }, }; } From 94d116bd757d1f2d4734dc2e87d5dfbe5fb1882d Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:44:25 +0200 Subject: [PATCH 3/6] feat(vue): enable submission without schema in useFormRelay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composable now always creates a client (createForm handles optional publicKey). submit() works without a schema — the client constructs the URL from formId. canSubmit and bot protection watcher fall back to options.botProtection when no schema exists. --- .../vue/src/composables/useFormRelay.test.ts | 27 ++++++++++++++----- packages/vue/src/composables/useFormRelay.ts | 18 ++++++++----- packages/vue/src/types.ts | 2 ++ 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/vue/src/composables/useFormRelay.test.ts b/packages/vue/src/composables/useFormRelay.test.ts index 2808427..ac4ffee 100644 --- a/packages/vue/src/composables/useFormRelay.test.ts +++ b/packages/vue/src/composables/useFormRelay.test.ts @@ -332,7 +332,7 @@ describe("useFormRelay", () => { expect(submitted.value).toBe(false); }); - test("does not submit when schema is not loaded", async () => { + test("submits even when schema is not yet loaded (client handles fetch)", async () => { mockGetSchema.mockReturnValue(new Promise(() => {})); // never resolves const { submit } = useFormRelay({ @@ -342,7 +342,7 @@ describe("useFormRelay", () => { await submit(); - expect(mockSubmit).not.toHaveBeenCalled(); + expect(mockSubmit).toHaveBeenCalled(); }); test("canSubmit is false while bot protection token is missing", () => { @@ -465,16 +465,31 @@ describe("useFormRelay", () => { expect(Object.keys(values)).toEqual([]); }); - test("submit is a no-op when no schema is available", async () => { - const { submit, values, submitting } = useFormRelay({ + test("submits without schema when no publicKey is provided", async () => { + const { submit, values, submitted } = useFormRelay({ formId: "01abc", }); values.email = "john@example.com"; await submit(); - expect(mockSubmit).not.toHaveBeenCalled(); - expect(submitting.value).toBe(false); + expect(mockSubmit).toHaveBeenCalledWith( + { email: "john@example.com" }, + {}, + ); + expect(submitted.value).toBe(true); + }); + + test("canSubmit is false when options.botProtection is set and no token provided", () => { + const { canSubmit, setBotToken } = useFormRelay({ + formId: "01abc", + botProtection: { type: "turnstile", siteKey: "0x-key" }, + }); + + expect(canSubmit.value).toBe(false); + + setBotToken("token"); + expect(canSubmit.value).toBe(true); }); test("uses initialSchema without publicKey for display-only rendering", () => { diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index 9a2b735..eeef0f6 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -5,9 +5,11 @@ import type { BotProtectionWidget } from "@formrelay/core/bot-protection"; import type { UseFormRelayOptions, UseFormRelayReturn } from "../types"; export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { - const client = options.publicKey - ? createForm(options.formId, { publicKey: options.publicKey }) - : null; + const client = createForm(options.formId, { + publicKey: options.publicKey, + botProtection: options.botProtection, + honeypotField: options.honeypotField, + }); const schema = ref(null); const schemaLoading = ref(!options.initialSchema && !!options.publicKey); @@ -26,7 +28,9 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { const validationSchema = computed( () => schema.value?.validationSchema ?? null, ); - const botProtection = computed(() => schema.value?.botProtection ?? null); + const botProtection = computed( + () => schema.value?.botProtection ?? options.botProtection ?? null, + ); const canSubmit = computed(() => { if (submitting.value) return false; @@ -52,7 +56,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { schemaError.value = null; try { - const loadedSchema = await client!.getSchema(); + const loadedSchema = await client.getSchema(); schema.value = loadedSchema; initializeValues(loadedSchema); } catch (error) { @@ -71,11 +75,11 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { } async function submit() { - if (!client || !schema.value || !canSubmit.value) return; + if (!canSubmit.value) return; errors.value = {}; - if (options.validate) { + if (schema.value && options.validate) { const validationErrors = options.validate({ ...values }, schema.value.validationSchema); if (Object.keys(validationErrors).length > 0) { errors.value = validationErrors; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 355aa03..5143fb1 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -11,6 +11,8 @@ export interface UseFormRelayOptions { formId: string; publicKey?: string; initialSchema?: FormSchema; + botProtection?: BotProtection; + honeypotField?: string; botProtectionContainer?: Ref; validate?: (data: Record, schema: JsonSchema) => Record; onSuccess?: (result: { message: string }) => void; From 479cfff527eafd4caed1d06efab6db14879a1f84 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:56:04 +0200 Subject: [PATCH 4/6] feat(vue,nuxt): add botProtection and honeypotField props to FormRelay Both Vue and Nuxt components now accept botProtection and honeypotField as optional props for manual form building without a schema. --- .../nuxt/src/runtime/components/FormRelay.ts | 9 ++++++- packages/vue/src/components/FormRelay.test.ts | 27 +++++++++++++++++++ packages/vue/src/components/FormRelay.ts | 9 ++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/nuxt/src/runtime/components/FormRelay.ts b/packages/nuxt/src/runtime/components/FormRelay.ts index 7a98962..71dd5f1 100644 --- a/packages/nuxt/src/runtime/components/FormRelay.ts +++ b/packages/nuxt/src/runtime/components/FormRelay.ts @@ -1,6 +1,6 @@ import { defineComponent, toRef, type PropType, type Ref } from "vue"; import { renderFormRelay } from "@formrelay/vue"; -import type { FormRelayError, JsonSchema } from "@formrelay/core"; +import type { BotProtection, FormRelayError, JsonSchema } from "@formrelay/core"; import { useFormRelay } from "../composables/useFormRelay"; // No initialSchema prop — the Nuxt composable handles SSR schema @@ -14,6 +14,11 @@ export default defineComponent({ type: Object as PropType>, default: undefined, }, + botProtection: { + type: Object as PropType, + default: undefined, + }, + honeypotField: { type: String, default: undefined }, validate: { type: Function as PropType< (data: Record, schema: JsonSchema) => Record @@ -34,6 +39,8 @@ export default defineComponent({ formId: props.formId, publicKey: props.publicKey, botProtectionContainer: toRef(props, "botProtectionContainer"), + botProtection: props.botProtection, + honeypotField: props.honeypotField, validate: props.validate, onSuccess: props.onSuccess, onError: props.onError, diff --git a/packages/vue/src/components/FormRelay.test.ts b/packages/vue/src/components/FormRelay.test.ts index 53bb803..3c461bf 100644 --- a/packages/vue/src/components/FormRelay.test.ts +++ b/packages/vue/src/components/FormRelay.test.ts @@ -353,4 +353,31 @@ describe("FormRelay", () => { expect(slotProps.schemaLoading).toBe(false); expect(slotProps.fields).toEqual([]); }); + + test("submits without publicKey when no schema is needed", async () => { + const { createForm } = await import("@formrelay/core"); + const mockSubmitFn = vi.fn().mockResolvedValue({ success: true, message: "OK" }); + vi.mocked(createForm).mockReturnValueOnce({ + getSchema: vi.fn(), + submit: mockSubmitFn, + } as any); + + let slotProps: any; + + mount(FormRelay, { + props: { formId: "01abc" }, + slots: { + default: (props: any) => { + slotProps = props; + return h("div"); + }, + }, + }); + + slotProps.values.email = "john@example.com"; + await slotProps.submit(); + + expect(mockSubmitFn).toHaveBeenCalled(); + expect(slotProps.submitted).toBe(true); + }); }); diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index f6e1113..10402fe 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -1,7 +1,7 @@ import { defineComponent, toRef, type PropType, type Ref } from "vue"; import { useFormRelay } from "../composables/useFormRelay"; import { renderFormRelay } from "./renderFormRelay"; -import type { FormRelayError, FormSchema, JsonSchema } from "@formrelay/core"; +import type { BotProtection, FormRelayError, FormSchema, JsonSchema } from "@formrelay/core"; export default defineComponent({ name: "FormRelay", @@ -13,6 +13,11 @@ export default defineComponent({ type: Object as PropType>, default: undefined, }, + botProtection: { + type: Object as PropType, + default: undefined, + }, + honeypotField: { type: String, default: undefined }, validate: { type: Function as PropType< (data: Record, schema: JsonSchema) => Record @@ -34,6 +39,8 @@ export default defineComponent({ publicKey: props.publicKey, initialSchema: props.initialSchema, botProtectionContainer: toRef(props, "botProtectionContainer"), + botProtection: props.botProtection, + honeypotField: props.honeypotField, validate: props.validate, onSuccess: props.onSuccess, onError: props.onError, From 705d5b8021fe0cedd08fb9af0401b18995150de0 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:57:09 +0200 Subject: [PATCH 5/6] chore: add changeset for submit-without-schema --- .changeset/submit-without-schema.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/submit-without-schema.md diff --git a/.changeset/submit-without-schema.md b/.changeset/submit-without-schema.md new file mode 100644 index 0000000..f8b9592 --- /dev/null +++ b/.changeset/submit-without-schema.md @@ -0,0 +1,11 @@ +--- +"@formrelay/core": minor +"@formrelay/vue": minor +"@formrelay/nuxt": minor +--- + +Enable form submission without fetching a schema. `publicKey` is now optional on `createForm` — when omitted, `submit()` constructs the URL from `formId` and the API base URL. + +New optional `botProtection` and `honeypotField` options on `createForm`, `useFormRelay`, and the `` component allow SDK-managed bot protection and honeypot without a schema fetch. + +Users who handle bot protection and honeypot entirely manually can omit these options and include the fields directly in form values. From 92d6af6c4679bd7956834cc44063cb30a3b3a808 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:44:25 +0200 Subject: [PATCH 6/6] fix: restore composable changes and forward options in Nuxt - Restore Task 3 composable changes (always-create client, submit guard, botProtection fallback) that were lost during Task 4 subagent stash - Forward botProtection and honeypotField through Nuxt composable to Vue composable - Add comment explaining validate is skipped in schema-less mode --- packages/nuxt/src/runtime/composables/useFormRelay.ts | 2 ++ packages/vue/src/composables/useFormRelay.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/nuxt/src/runtime/composables/useFormRelay.ts b/packages/nuxt/src/runtime/composables/useFormRelay.ts index 2db6ac1..b0c3d94 100644 --- a/packages/nuxt/src/runtime/composables/useFormRelay.ts +++ b/packages/nuxt/src/runtime/composables/useFormRelay.ts @@ -74,6 +74,8 @@ export async function useFormRelay(options: Partial & { for publicKey, initialSchema: initialSchema?.value ?? undefined, botProtectionContainer: options.botProtectionContainer, + botProtection: options.botProtection, + honeypotField: options.honeypotField, validate: options.validate, onSuccess: options.onSuccess, onError: options.onError, diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index eeef0f6..e177d7f 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -79,6 +79,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { errors.value = {}; + // validate requires a schema for the validationSchema — skipped in schema-less mode if (schema.value && options.validate) { const validationErrors = options.validate({ ...values }, schema.value.validationSchema); if (Object.keys(validationErrors).length > 0) {