@formbuilder-cloud/adapter
v0.1.1
Published
Connect formRender (formBuilder 3.23.1+) to formBuilder Cloud: render a published form or attach to an existing one, submit with an Idempotency-Key and safe retries, and show every documented outcome. No telemetry.
Maintainers
Readme
@formbuilder-cloud/adapter
Connects formRender (formBuilder 3.23.1 and later 3.x) to formBuilder Cloud. It renders a published form, or attaches to a form you already render. It then submits the values formRender's userData produces, with an Idempotency-Key and safe retries, and shows every documented outcome. It is a separate, opt-in package: formBuilder and formRender are unchanged and never call Cloud themselves, and removing the adapter leaves your local formRender working exactly as before.
- No telemetry. Importing the package or loading the script makes no request. After
render()orattach()it requests onlyGET /v1/forms/{f}/publishedandPOSTto that document'sendpoint.url, both onapiBase. It loads Cloudflare Turnstile only when the form's owner turned the challenge on. - Collection keys only.
keymust be the form's public collection key (fb_col_…). A management key or a JWT throwsCloudConfigErrorbefore any request: management credentials must never be used in a browser. - Version 0.1.0, not published yet. The npm org exists only once Kevin creates it. Until the first release, the script-tag snippets keep their
<VERSION>and<SRI>placeholders.
Builds
| File | Use | Notes |
|---|---|---|
| dist/index.js (+ dist/index.d.ts) | bundlers (ESM) | needs jquery and formBuilder (peer dependencies) and window.jQuery set |
| dist/cloud.min.js | a page that already loads jQuery and formRender | IIFE, sets only window.FormBuilderCloud; at most 10 KB gzip (checked in CI) |
| dist/embed.min.js | a page with nothing on it | the same API plus the unmodified jQuery 3.7.1 and formRender 3.23.1, which keep their MIT notices (THIRD-PARTY-NOTICES.md); it defines window.jQuery/$ only when the page has none |
Script tag
The dashboard's embed tab, GET /v1/forms/{f}/embed and the MCP get_embed_snippet tool all serve the same snippet (S-11 S11-19, shared/src/snippets.ts):
<div id="fb-form"></div>
<script src="https://cdn.jsdelivr.net/npm/@formbuilder-cloud/adapter@<VERSION>/dist/cloud.min.js" integrity="<SRI>" crossorigin="anonymous"></script>
<script>FormBuilderCloud.render({ el: '#fb-form', formId: '<FORM_ID>', key: '<YOUR_fb_col_KEY>' })</script>
<noscript>This form needs JavaScript.</noscript>Always pin an exact version and its SRI hash. If you swap the src for …/dist/embed.min.js, use that file's hash: an integrity value belongs to one file. Both hashes are printed at release (RELEASING.md).
npm
import $ from 'jquery'
import { render } from '@formbuilder-cloud/adapter'
;(window as unknown as { jQuery: unknown; $: unknown }).jQuery = (window as unknown as { $: unknown }).$ = $
await import('formBuilder/dist/form-render.min.js')
const form = await render({
el: '#fb-form',
formId: '<FORM_ID>',
key: '<YOUR_fb_col_KEY>',
onAccepted: ({ id, replayed }) => console.log('stored', id, replayed),
})
console.log(form.state) // 'ready'An existing formRender installation
Keep your own formRender() call and markup, and add attach() (S11-20). First import and publish the same definition in Cloud (dashboard "Import JSON", or PUT /draft with a management key from a server). Field names are the identity, and attach() warns in the console when your local names and the published version's differ.
import { attach, type FormRenderInstance } from '@formbuilder-cloud/adapter'
declare const $: (selector: string) => { formRender(options: { formData: unknown[] }): FormRenderInstance }
declare const myDefinition: unknown[]
const fr = $('#my-fields').formRender({ formData: myDefinition }) // your unchanged code
await attach({ form: document.getElementById('my-form') as HTMLFormElement, formRender: fr, formId: '<FORM_ID>', key: '<YOUR_fb_col_KEY>' })attach() leaves your markup and styles alone: no class and no theme unless you pass theme: 'published' or tokens. Call formRender once per form: an instance that rendered several containers is refused, because formRender renames the later containers' fields.
Without the adapter (plain HTTP)
The adapter is optional. examples/plain-fetch.html and the curl snippet in shared/src/snippets.ts send the same body with fetch or curl: { values, meta: { form_version, client } } with an Idempotency-Key header. Reuse the exact key and the exact body on a retry to get the original 201 with Idempotent-Replayed: true.
API
import { createClient, collectValues, CloudProblem, CloudConfigError, DEFAULT_MESSAGES, VERSION } from '@formbuilder-cloud/adapter'
import type { RenderOptions, AttachOptions, CloudForm, CloudClient, State, Messages, ThemeTokens } from '@formbuilder-cloud/adapter'
const client: CloudClient = createClient({ formId: '<FORM_ID>', key: '<YOUR_fb_col_KEY>' })
const doc = await client.published()
const result = await client.submit({ values: { email: ['[email protected]'] }, meta: { form_version: doc.version } }, { idempotencyKey: crypto.randomUUID() })
console.log(result.replayed, VERSION, DEFAULT_MESSAGES.success, collectValues, CloudProblem, CloudConfigError)
type Uses = [RenderOptions, AttachOptions, CloudForm, State, Messages, ThemeTokens]render({ el, formId, key, apiBase?, messages?, idempotency?, formRenderOptions?, submitLabel?, afterAccepted?, theme?, onAccepted?, onError?, onStateChange? })creates<form class="fb-cloud" novalidate>insideel. It renders one container with$(container).formRender({ ...formRenderOptions, formData: <deep copy>, dataType: 'json' })and appends a submit button when the definition has none.afterAcceptedis'message'(default: the message replaces the form),'reset'or'keep'.attach({ form, formRender, formId, key, formVersion?, theme?, … })does the same for your own form. WithoutformVersion, the server validates against the active version.CloudForm:state,submit()(resolves with the 201 body plusreplayed, rejects withCloudProblem),reload()anddestroy().destroy()removes the adapter's nodes, listeners, theme andsessionStorageentry.collectValues(instance, { quill? })returns formRender'suserDataasname → string[], verbatim. It also reads Quill fields, which formRender's getter never includes.createClient({ formId, key, apiBase? })givespublished()andsubmit(body, { idempotencyKey }), with the same retries.CloudProblemhasstatus,type(an API problem type,'network'or'unknown'),detail,errors,retryAfterandrequestId.CloudConfigErroris thrown synchronously for a bad option.
States and messages
loading, ready, submitting, accepted, invalid, stale, closed, unavailable and error. Success is shown only after a 201, and it says "received", never "delivered". Values are never cleared on a failure. Every string is in messages (DEFAULT_MESSAGES); unavailable takes {minutes}. The adapter adds only the classes fb-cloud, fb-cloud__message, fb-cloud__field-error and fb-cloud__summary.
| Answer | What the respondent sees |
|---|---|
| 201 (a replay too) | the success message; onAccepted |
| 400 validation-failed | a message next to each field (aria-invalid, aria-describedby, setCustomValidity), a summary (role="alert") for fields that are not on the page, and focus on the first invalid field; editing a field clears its error |
| 400 rejected-spam | "Your response could not be submitted." |
| 400 invalid-body / invalid-idempotency-key, 415 | a generic error; the console names the problem type (an integration bug) |
| 401, 403, 404 | "temporarily unavailable"; the console names the form and the key's first 12 characters |
| 403 origin-not-allowed | "temporarily unavailable"; the console asks you to add location.origin to the form's allowed origins |
| 409 idempotency-conflict | "Please submit again." (a new key next time) |
| 410 version-retired / version-unknown | the new version is fetched and rendered with the answers kept; nothing is submitted automatically |
| 410 form-archived / workspace-suspended, 429 quota-exceeded | "This form is no longer accepting responses." |
| 410 collection-paused, 429 rate-limited over 60 s | "Please try again in N minutes." |
| 413 | "Your response is too large." |
| network, 500, 502, 503, 504 | up to 3 automatic retries (1, 3, 9 s ±20 %, at least Retry-After, at most 30 s), then "We couldn't send your response" + Try again |
Idempotency and retries
One key per logical submission, i.e. per canonical (sorted-key) body: retries, a double click and a page reload reuse it, and a changed answer gets a new one. With idempotency: 'session' (the default) sessionStorage keeps { key, bodySha256, createdAt } under fb-cloud:idem:<formId> for at most 23 hours; values are never stored. On an insecure page (no crypto.subtle) the key lives in memory. A retry resends identical bytes, so the server replays its first 201 instead of answering 409. A 429 rate-limited with Retry-After of 60 s or less is retried once. Nothing is queued offline.
Themes
With theme: 'published' (the default for render()), the adapter applies the form's published theme tokens (document.theme, issue #19). It first checks every token with the same validator as the API (hex colours, integers in range and fixed enums only). It then sets them only through style.setProperty('--fbc-…') on its own form, and inserts one constant stylesheet whose selectors are all under .fb-cloud[data-fbc-theme]. If any token is invalid or unknown, the whole theme is ignored with a console.warn, and formRender's own look is kept. 'none' turns themes off; a ThemeTokens object applies your own. No theme ever adds a logo, a badge or a link. The Turnstile widget is dark on a dark background.
Turnstile
When the form's owner enabled the challenge, the adapter loads https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit once and renders the widget before the submit button. It sends the token as meta.challenge_token, and resets the widget after a 201 and after any 4xx answer, because a verified token is spent even when the submission then fails.
Content Security Policy
connect-src https://api.formbuilder.online(yourapiBase);script-srcfor the adapter's and formRender's origins (jsDelivr or your own host);style-src 'unsafe-inline', because formRender injects style tags (themes need nothing more);- with Turnstile:
script-src https://challenges.cloudflare.comandframe-src https://challenges.cloudflare.com; - the CDN origins of formRender's rich-text and plugin controls, only if you use them (Quill:
cdn.quilljs.com; TinyMCE, starRating, Trumbowyg:cdnjs.cloudflare.com); - formRender's translations: on its first render, formRender fetches
<i18n.location>en-US.lang(defaultassets/lang/, relative to the page). That is library behaviour, harmless when it fails. Allow it withconnect-src 'self', or setformRenderOptions.i18n.locationto a URL you serve.
Support
- formRender 3.23.1 (
adapter/test/versions.json; peer range>=3.23.1 <4), jQuery 3.7. - The last two versions of Chrome, Edge, Firefox and Safari, and iOS Safari 16+. CI runs the example pages in Chromium.
- No React or Vue wrapper packages. The React, Vue and Next.js templates (
npm create @formbuilder-cloud,examples/in the formbuilder-cloud repository) show the component aroundrender()anddestroy().
Leaving Cloud
Remove the script tag or the import. Export your definitions (their definition loads straight into formData) and your submissions. Then post $(…).formRender('userData') to your own endpoint. The adapter leaves nothing behind: no cookies, no other globals, and its sessionStorage entry goes after a 201, after 23 hours, or on destroy().
Licence
UNLICENSED: proprietary. Copyright (c) 2026 Kevin Chappell, all rights reserved. You may use it only with the formBuilder Cloud service and under that service's terms (LICENSE). dist/embed.min.js also contains jQuery and formRender, which stay under their own MIT licences. Their notices are in THIRD-PARTY-NOTICES.md and at the top of that file.
