@usefillo/dom 0.14.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Field, FieldValue, FormSchema, FilloClient, ResponseData, FormStatus as FormStatus$1, FormPage, Block, FilloRespondent, CodeForm, FormTheme, ChallengeConfig, FieldKind, FilloError, FormControllerOptions, FormController } from '@usefillo/core';
1
+ import { Field, FieldValue, FormSchema, FilloClient, ResponseData, FormStatus as FormStatus$1, FormPage, Block, FilloRespondent, CodeForm, FormTheme, ChallengeConfig, ChallengeTheme, FieldKind, FilloError, FormControllerOptions, FormController } from '@usefillo/core';
2
2
  export { CodeForm, Field, FieldKind, FieldValue, FileValue, FilloClient, FilloClientOptions, FilloError, FormController, FormControllerOptions, FormControllerState, FormSchema, FormTheme, ProvisionWorkspaceResult, ResponseData, createClient, defineForm, provisionWorkspace } from '@usefillo/core';
3
3
 
4
4
  type FormStatus = FormStatus$1 | "loading" | "closed";
@@ -59,6 +59,18 @@ interface RenderFormBaseOptions {
59
59
  * hosted page does this). Wins over anything the server delivers.
60
60
  */
61
61
  challenge?: ChallengeConfig;
62
+ /**
63
+ * Theme for the human-verification widget. Defaults to "auto" (the
64
+ * visitor's OS preference); apps with their own theme switch pass
65
+ * "light"/"dark" so the widget matches the surrounding form.
66
+ */
67
+ challengeTheme?: ChallengeTheme;
68
+ /**
69
+ * Bridge-mode visibility for the human check. "interaction-only" (default)
70
+ * keeps it invisible unless Cloudflare needs the visitor to act; "always"
71
+ * shows the classic widget box the whole time.
72
+ */
73
+ challengeAppearance?: "always" | "interaction-only";
62
74
  className?: string;
63
75
  components?: Partial<Record<FieldKind, FieldRenderer>>;
64
76
  customComponents?: Record<string, FieldRenderer>;
package/dist/index.js CHANGED
@@ -1758,6 +1758,8 @@ function defaultFieldRenderer(context) {
1758
1758
  }
1759
1759
  var TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
1760
1760
  var TURNSTILE_SCRIPT_ID = "fillo-turnstile-script";
1761
+ var BRIDGE_READY_TIMEOUT_MS = 2e4;
1762
+ var CHALLENGE_CDATA_RE = /^[A-Za-z0-9_-]{1,255}$/;
1761
1763
  var turnstileScriptPromise = null;
1762
1764
  function turnstileGlobal() {
1763
1765
  return globalThis.turnstile;
@@ -1883,6 +1885,15 @@ var DomFormController = class {
1883
1885
  // Invalidated on teardown so a stale load/render callback can't land after
1884
1886
  // a later attempt replaced the container (or double-render into it).
1885
1887
  turnstileAttempt = 0;
1888
+ // Bridge mode (challenge.bridgeUrl): the Fillo-hosted iframe that runs the
1889
+ // widget on Fillo's OWN hostname (so the check works on any embedding
1890
+ // domain) plus its postMessage wiring. Mutually exclusive with the direct
1891
+ // Cloudflare-script fields above; teardownTurnstileWidget() clears both.
1892
+ challengeFrame = null;
1893
+ challengeFrameSrc = null;
1894
+ challengeFrameOrigin = null;
1895
+ challengeMessageHandler = null;
1896
+ challengeWatchdog = null;
1886
1897
  // Clear-then-set (one frame apart) so an identical repeat still re-fires —
1887
1898
  // a same-tick ""→text write can collapse into a no-op net change for AT.
1888
1899
  announce = (text) => {
@@ -2503,6 +2514,142 @@ var DomFormController = class {
2503
2514
  }
2504
2515
  this.overlays.clear();
2505
2516
  }
2517
+ /** Route the challenge to bridge or direct mode per the server's config.
2518
+ * Bridge (bridgeUrl present — every current server) works on any embedding
2519
+ * domain; direct is the pre-bridge fallback for older self-hosted servers.
2520
+ * Message contract mirrors @usefillo/react's turnstile module; keep both
2521
+ * in sync with apps/web src/app/embed/challenge/route.ts. */
2522
+ ensureChallengeWidget(challenge) {
2523
+ if (challenge.bridgeUrl) this.ensureChallengeBridge(challenge.bridgeUrl);
2524
+ else this.ensureTurnstileWidget(challenge.siteKey);
2525
+ }
2526
+ /** Idempotent per src: mounts the Fillo-hosted bridge iframe into the
2527
+ * persistent container and wires its postMessage token flow. */
2528
+ ensureChallengeBridge(bridgeUrl) {
2529
+ if (typeof window === "undefined") return;
2530
+ let url;
2531
+ try {
2532
+ url = new URL(bridgeUrl);
2533
+ } catch {
2534
+ this.handleChallengeError();
2535
+ return;
2536
+ }
2537
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
2538
+ this.handleChallengeError();
2539
+ return;
2540
+ }
2541
+ url.searchParams.set("origin", window.location.origin);
2542
+ url.searchParams.set("theme", this.options.challengeTheme ?? "auto");
2543
+ if (this.formId && CHALLENGE_CDATA_RE.test(this.formId)) {
2544
+ url.searchParams.set("cdata", this.formId);
2545
+ }
2546
+ url.searchParams.set(
2547
+ "appearance",
2548
+ (this.options.challengeAppearance ?? "interaction-only") === "interaction-only" ? "interaction-only" : "always"
2549
+ );
2550
+ const src = url.toString();
2551
+ if (this.challengeFrame && this.challengeFrameSrc === src) return;
2552
+ this.teardownTurnstileWidget();
2553
+ const attempt = ++this.turnstileAttempt;
2554
+ const frame = el("iframe", {
2555
+ className: "fillo-turnstile-frame",
2556
+ attrs: {
2557
+ src,
2558
+ title: "Human verification",
2559
+ // Cloudflare's normal widget footprint; attributes so host CSS wins.
2560
+ width: "300",
2561
+ height: "65",
2562
+ referrerpolicy: "no-referrer"
2563
+ }
2564
+ });
2565
+ this.challengeFrame = frame;
2566
+ this.challengeFrameSrc = src;
2567
+ this.challengeFrameOrigin = url.origin;
2568
+ this.turnstileContainer.appendChild(frame);
2569
+ this.setChallengeFrameVisible(
2570
+ (this.options.challengeAppearance ?? "interaction-only") !== "interaction-only"
2571
+ );
2572
+ const onMessage = (event) => {
2573
+ if (attempt !== this.turnstileAttempt || this.destroyed) return;
2574
+ if (event.origin !== this.challengeFrameOrigin) return;
2575
+ if (!this.challengeFrame || event.source !== this.challengeFrame.contentWindow) return;
2576
+ const data = event.data;
2577
+ switch (data?.type) {
2578
+ case "fillo:challenge:ready":
2579
+ this.settleChallengeWatchdog();
2580
+ break;
2581
+ case "fillo:challenge:token":
2582
+ this.settleChallengeWatchdog();
2583
+ if (typeof data.token === "string" && data.token.length > 0) {
2584
+ this.handleChallengeToken(data.token);
2585
+ }
2586
+ if ((this.options.challengeAppearance ?? "interaction-only") === "interaction-only") {
2587
+ this.setChallengeFrameVisible(false);
2588
+ }
2589
+ break;
2590
+ case "fillo:challenge:interactive":
2591
+ this.setChallengeFrameVisible(true);
2592
+ break;
2593
+ case "fillo:challenge:interactive-done":
2594
+ if ((this.options.challengeAppearance ?? "interaction-only") === "interaction-only") {
2595
+ this.setChallengeFrameVisible(false);
2596
+ }
2597
+ break;
2598
+ case "fillo:challenge:expired":
2599
+ this.handleChallengeToken(void 0);
2600
+ break;
2601
+ case "fillo:challenge:error":
2602
+ this.settleChallengeWatchdog();
2603
+ this.handleChallengeToken(void 0);
2604
+ this.handleChallengeError();
2605
+ break;
2606
+ }
2607
+ };
2608
+ this.challengeMessageHandler = onMessage;
2609
+ window.addEventListener("message", onMessage);
2610
+ this.challengeWatchdog = window.setTimeout(() => {
2611
+ if (attempt !== this.turnstileAttempt || this.destroyed) return;
2612
+ this.handleChallengeToken(void 0);
2613
+ this.handleChallengeError();
2614
+ }, BRIDGE_READY_TIMEOUT_MS);
2615
+ }
2616
+ /** Collapse/expand the bridge frame (interaction-only mode). The frame stays
2617
+ * mounted either way — height 0 keeps the invisible check running. */
2618
+ setChallengeFrameVisible(visible) {
2619
+ const frame = this.challengeFrame;
2620
+ if (!frame) return;
2621
+ frame.setAttribute("height", visible ? "65" : "0");
2622
+ if (visible) {
2623
+ frame.removeAttribute("aria-hidden");
2624
+ frame.removeAttribute("tabindex");
2625
+ } else {
2626
+ frame.setAttribute("aria-hidden", "true");
2627
+ frame.setAttribute("tabindex", "-1");
2628
+ }
2629
+ this.turnstileContainer.setAttribute(
2630
+ "data-fillo-challenge-visible",
2631
+ visible ? "true" : "false"
2632
+ );
2633
+ }
2634
+ settleChallengeWatchdog() {
2635
+ if (this.challengeWatchdog !== null && typeof window !== "undefined") {
2636
+ window.clearTimeout(this.challengeWatchdog);
2637
+ }
2638
+ this.challengeWatchdog = null;
2639
+ }
2640
+ /** Unwire and remove the bridge frame (listener, watchdog, iframe). Safe to
2641
+ * call when nothing is mounted. */
2642
+ teardownChallengeBridge() {
2643
+ if (this.challengeMessageHandler && typeof window !== "undefined") {
2644
+ window.removeEventListener("message", this.challengeMessageHandler);
2645
+ }
2646
+ this.challengeMessageHandler = null;
2647
+ this.settleChallengeWatchdog();
2648
+ this.challengeFrame?.remove();
2649
+ this.challengeFrame = null;
2650
+ this.challengeFrameSrc = null;
2651
+ this.challengeFrameOrigin = null;
2652
+ }
2506
2653
  /** Idempotent: at most one load+render per armed container (guarded by
2507
2654
  * widget id / in-flight / already-failed); re-arms on a changed site key
2508
2655
  * (mirrors react's effect, keyed on siteKey). */
@@ -2558,7 +2705,14 @@ var DomFormController = class {
2558
2705
  * expiry, or timeout) — not a teardown, the widget stays mounted. */
2559
2706
  resetChallengeWidget() {
2560
2707
  this.challengeToken = void 0;
2561
- if (this.turnstileWidgetId) turnstileGlobal()?.reset(this.turnstileWidgetId);
2708
+ if (this.challengeFrame && this.challengeFrameOrigin) {
2709
+ this.challengeFrame.contentWindow?.postMessage(
2710
+ { type: "fillo:challenge:reset" },
2711
+ this.challengeFrameOrigin
2712
+ );
2713
+ } else if (this.turnstileWidgetId) {
2714
+ turnstileGlobal()?.reset(this.turnstileWidgetId);
2715
+ }
2562
2716
  this.queueRender();
2563
2717
  }
2564
2718
  /** Fully remove any active/pending widget (mirrors react's unmount effect):
@@ -2566,8 +2720,11 @@ var DomFormController = class {
2566
2720
  * fresh container replaces the old one for a pristine next mount. Safe to
2567
2721
  * call when nothing is active. */
2568
2722
  teardownTurnstileWidget() {
2569
- if (this.turnstileWidgetId === null && !this.turnstileLoadInFlight) return;
2723
+ if (this.turnstileWidgetId === null && !this.turnstileLoadInFlight && this.challengeFrame === null) {
2724
+ return;
2725
+ }
2570
2726
  this.turnstileAttempt++;
2727
+ this.teardownChallengeBridge();
2571
2728
  const api = turnstileGlobal();
2572
2729
  if (this.turnstileWidgetId && api) {
2573
2730
  try {
@@ -3039,8 +3196,8 @@ var DomFormController = class {
3039
3196
  );
3040
3197
  }
3041
3198
  content.appendChild(slot);
3042
- this.ensureTurnstileWidget(this.challenge.siteKey);
3043
- } else if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight) {
3199
+ this.ensureChallengeWidget(this.challenge);
3200
+ } else if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeFrame !== null) {
3044
3201
  this.teardownTurnstileWidget();
3045
3202
  }
3046
3203
  const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;