@rozie-ui/captcha-react 0.1.3

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.
@@ -0,0 +1,109 @@
1
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
2
+ import { clsx, useControllableState } from '@rozie/runtime-react';
3
+ // The v3 api.js loader (inject-once-per-sitekey singleton + ready-gate + token
4
+ // execute) lives in a vendored internal module so its branchy logic is
5
+ // unit-tested independent of any framework (see internal/loadRecaptchaV3.test.ts).
6
+ // codegen copies src/internal/ into every leaf, so this import resolves ×6.
7
+ import { loadRecaptchaV3, execute as v3Execute } from './internal/loadRecaptchaV3';
8
+
9
+ // `disposed` MUST be top-level (not declared inside $onMount): the Solid emitter
10
+ // extracts the teardown into a separate onCleanup() whose scope can't see a
11
+ // mount-body local, so a `let disposed` inside $onMount is out of scope in the
12
+ // teardown. Top-level — visible to both the mount body and the teardown. It also
13
+ // guards a late execute() resolve that fires after the component unmounts.
14
+
15
+ interface RecaptchaV3Props {
16
+ sitekey: string;
17
+ action?: string;
18
+ token?: string;
19
+ defaultToken?: string;
20
+ onTokenChange?: (token: string) => void;
21
+ executeOnMount?: boolean;
22
+ onError?: (...args: any[]) => void;
23
+ onVerify?: (...args: any[]) => void;
24
+ }
25
+
26
+ export interface RecaptchaV3Handle {
27
+ execute: (...args: any[]) => any;
28
+ }
29
+
30
+ const RecaptchaV3 = forwardRef<RecaptchaV3Handle, RecaptchaV3Props>(function RecaptchaV3(_props: RecaptchaV3Props, ref): JSX.Element {
31
+ const props: Omit<RecaptchaV3Props, 'action' | 'executeOnMount'> & { action: string; executeOnMount: boolean } = {
32
+ ..._props,
33
+ action: _props.action ?? 'submit',
34
+ executeOnMount: _props.executeOnMount ?? false,
35
+ };
36
+ const attrs: Record<string, unknown> = (() => {
37
+ const { sitekey, action, token, executeOnMount, defaultValue, onTokenChange, defaultToken, ...rest } = _props as RecaptchaV3Props & Record<string, unknown>;
38
+ void sitekey; void action; void token; void executeOnMount; void defaultValue; void onTokenChange; void defaultToken;
39
+ return rest;
40
+ })();
41
+ const disposed = useRef(false);
42
+ const [token, setToken] = useControllableState({
43
+ value: props.token,
44
+ defaultValue: props.defaultToken ?? '',
45
+ onValueChange: props.onTokenChange,
46
+ });
47
+
48
+ // Run a v3 challenge and return a fresh token. The optional `action` arg
49
+ // overrides the prop default for this one call. On success writes the two-way
50
+ // token + emits @verify; on failure emits @error. NB: the resolved param must
51
+ // NOT be named `token` — on Vue, $model.token lowers to a `defineModel('token')`
52
+ // ref named `token`, and a same-named param shadows it (`token.value = token`
53
+ // would write the param). Use `tok` (mirrors Captcha.rozie's `response`).
54
+ //
55
+ // `action = null` (an explicit DEFAULT, not a bare `action`) makes the param
56
+ // OPTIONAL — required so the no-arg call in $onMount's executeOnMount path
57
+ // (`execute()`) typechecks. The type-neutralizer otherwise lowers a bare param
58
+ // to a REQUIRED `action: any`, which Vue's strict declaration emit (vue-tsc)
59
+ // rejects at the `execute()` call (TS2554) — the other five targets don't
60
+ // body-typecheck the emitted leaf, so the issue is Vue-only but real. The
61
+ // default is logic-neutral: the body already guards `action != null`.
62
+ function execute(action = null) {
63
+ const a = action != null ? action : props.action;
64
+ return loadRecaptchaV3(props.sitekey).then(() => v3Execute(props.sitekey, {
65
+ action: a
66
+ })).then((tok: any) => {
67
+ if (disposed.current) return tok;
68
+ setToken(tok);
69
+ props.onVerify && props.onVerify({
70
+ token: tok,
71
+ action: a
72
+ });
73
+ return tok;
74
+ }).catch((err: any) => {
75
+ if (!disposed.current) props.onError && props.onError({
76
+ error: err
77
+ });
78
+ throw err;
79
+ });
80
+ }
81
+
82
+ useEffect(() => {
83
+ disposed.current = false;
84
+ // Warm the script once for this sitekey. If opted in, run an initial execute.
85
+ loadRecaptchaV3(props.sitekey).then(() => {
86
+ if (disposed.current || !props.executeOnMount) return;
87
+ execute();
88
+ }).catch((err: any) => {
89
+ if (disposed.current) return;
90
+ props.onError && props.onError({
91
+ error: err
92
+ });
93
+ });
94
+ return () => {
95
+ disposed.current = true;
96
+ };
97
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
98
+
99
+ const _rozieExposeRef = useRef({ execute });
100
+ _rozieExposeRef.current = { execute };
101
+ useImperativeHandle(ref, () => ({ execute: (...args: Parameters<typeof execute>): ReturnType<typeof execute> => _rozieExposeRef.current.execute(...args) }), []);
102
+
103
+ return (
104
+ <>
105
+ <div style={{ display: "none" }} {...attrs} className={clsx("rozie-recaptcha-v3", (attrs.className as string | undefined))} data-rozie-s-9148a0b0="" />
106
+ </>
107
+ );
108
+ });
109
+ export default RecaptchaV3;
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { default as Captcha } from './Captcha';
2
+ export { default } from './Captcha';
3
+ export { default as RecaptchaV3 } from './RecaptchaV3';
4
+
5
+ /** The `$expose` imperative handle for Captcha received via `ref` — { reset, execute, getResponse }. */
6
+ export type { CaptchaHandle } from './Captcha';
7
+
8
+ /** The `$expose` imperative handle for RecaptchaV3 received via `ref` — { execute }. */
9
+ export type { RecaptchaV3Handle } from './RecaptchaV3';
@@ -0,0 +1,174 @@
1
+ // loadCaptchaApi.ts — provider api.js loader for the Captcha family.
2
+ //
3
+ // Extracted from Captcha.rozie so the one piece of real branchy logic (inject
4
+ // once, poll until ready, cache per provider, timeout, error) is unit-testable
5
+ // independent of any framework — the sortable-list `internal/useSortableJS`
6
+ // pattern. codegen.mjs vendors this file (minus *.test.ts) into every leaf's
7
+ // `src/internal/`, so the `./internal/loadCaptchaApi` import resolves verbatim
8
+ // in all six compiled outputs.
9
+ //
10
+ // Framework-agnostic: touches only `document`, `globalThis`, and timers.
11
+
12
+ /** The slice of a provider's global SDK this wrapper drives. */
13
+ export interface CaptchaApi {
14
+ render: (el: Element, cfg: Record<string, unknown>) => unknown;
15
+ reset?: (id: unknown) => void;
16
+ execute?: (id: unknown) => void;
17
+ getResponse?: (id: unknown) => string;
18
+ remove?: (id: unknown) => void;
19
+ }
20
+
21
+ export interface ProviderConfig {
22
+ /** The api.js URL (`?render=explicit` so we own the render call + widget id). */
23
+ src: string;
24
+ /** The global name the script installs (`grecaptcha` / `hcaptcha` / `turnstile` / `frcaptcha`). */
25
+ global: string;
26
+ /**
27
+ * Optional bridge from the raw resolved global to the `CaptchaApi` shape.
28
+ *
29
+ * The original three providers (recaptcha/hcaptcha/turnstile) share the
30
+ * `render(el, cfg)`→id / `reset` / `execute` / `getResponse` surface, so they
31
+ * declare NO `adapt` and resolution is IDENTITY — the raw global IS the
32
+ * `CaptchaApi` unchanged (byte-for-byte the same behavior as before this hook
33
+ * existed). Friendly Captcha exposes a different SDK shape (`createWidget`
34
+ * returning an event-emitter handle), so it supplies an `adapt` that maps
35
+ * that surface onto `CaptchaApi`. When present, `adapt` is applied to the
36
+ * resolved global BEFORE the loader promise resolves / caches, so the cached
37
+ * value is always the adapted `CaptchaApi`, never the raw global.
38
+ */
39
+ adapt?: (global: unknown) => CaptchaApi;
40
+ }
41
+
42
+ /**
43
+ * Bridge Friendly Captcha's `createWidget` SDK (the `@friendlycaptcha/sdk`
44
+ * compat build, loaded from the CDN — NO npm peer dependency) onto `CaptchaApi`.
45
+ *
46
+ * FC has no explicit-`render`/widget-id model: `createWidget({ element, ... })`
47
+ * returns an event-emitter handle (`frc:widget.complete|expire|error`) with
48
+ * `reset()` / `start()` / `getResponse()` / `destroy()`. We treat that handle AS
49
+ * the opaque widget id the rest of the family already threads around. FC also
50
+ * has no `size` concept — `startMode` is the closest analog and rides through
51
+ * the `options` escape hatch (or a `startMode` config key if a consumer passes
52
+ * one directly).
53
+ */
54
+ const adaptFriendly = (g: unknown): CaptchaApi => {
55
+ const fc = g as { createWidget: (opts: Record<string, unknown>) => FriendlyWidget };
56
+ return {
57
+ render(el: Element, cfg: Record<string, unknown>) {
58
+ const h = fc.createWidget({
59
+ element: el,
60
+ sitekey: cfg.sitekey,
61
+ theme: cfg.theme,
62
+ startMode: cfg.startMode,
63
+ });
64
+ h.addEventListener('frc:widget.complete', (e: FriendlyEvent) => {
65
+ const cb = cfg.callback as ((token: string) => void) | undefined;
66
+ if (typeof cb === 'function') cb(e.detail?.response ?? '');
67
+ });
68
+ h.addEventListener('frc:widget.expire', () => {
69
+ const cb = cfg['expired-callback'] as (() => void) | undefined;
70
+ if (typeof cb === 'function') cb();
71
+ });
72
+ h.addEventListener('frc:widget.error', () => {
73
+ const cb = cfg['error-callback'] as (() => void) | undefined;
74
+ if (typeof cb === 'function') cb();
75
+ });
76
+ return h;
77
+ },
78
+ reset: (id: unknown) => (id as FriendlyWidget).reset(),
79
+ execute: (id: unknown) => (id as FriendlyWidget).start(),
80
+ getResponse: (id: unknown) => (id as FriendlyWidget).getResponse(),
81
+ remove: (id: unknown) => (id as FriendlyWidget).destroy(),
82
+ };
83
+ };
84
+
85
+ /** The slice of an FC `createWidget` handle this bridge drives. */
86
+ interface FriendlyWidget {
87
+ addEventListener: (type: string, cb: (e: FriendlyEvent) => void) => void;
88
+ reset: () => void;
89
+ start: () => void;
90
+ getResponse: () => string;
91
+ destroy: () => void;
92
+ }
93
+ interface FriendlyEvent {
94
+ detail?: { response?: string };
95
+ }
96
+
97
+ export const CAPTCHA_PROVIDERS: Record<string, ProviderConfig> = {
98
+ recaptcha: { src: 'https://www.google.com/recaptcha/api.js?render=explicit', global: 'grecaptcha' },
99
+ hcaptcha: { src: 'https://js.hcaptcha.com/1/api.js?render=explicit', global: 'hcaptcha' },
100
+ turnstile: { src: 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit', global: 'turnstile' },
101
+ // Friendly Captcha — a different SDK shape (`createWidget`), bridged onto
102
+ // CaptchaApi via `adapt`. CDN compat build pinned to the major (`@1`); NO
103
+ // `@friendlycaptcha/sdk` npm dependency (the family's zero-peer-dep design).
104
+ friendly: {
105
+ src: 'https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@1/site.compat.min.js',
106
+ global: 'frcaptcha',
107
+ adapt: adaptFriendly,
108
+ },
109
+ };
110
+
111
+ /** How long to poll for the provider global before giving up (ms). */
112
+ export const LOAD_TIMEOUT_MS = 20000;
113
+
114
+ type LoaderCache = Record<string, Promise<CaptchaApi>>;
115
+
116
+ /**
117
+ * Load a provider's api.js ONCE across the whole document, shared by every
118
+ * `<Captcha>` instance via a `globalThis` singleton (a per-instance cache would
119
+ * re-inject per component). Resolves with the provider global once its
120
+ * `render()` is callable; rejects on an unknown provider, a script load error,
121
+ * or a `LOAD_TIMEOUT_MS` timeout. `providers` is injectable for tests.
122
+ */
123
+ export function loadCaptchaApi(
124
+ provider: string,
125
+ providers: Record<string, ProviderConfig> = CAPTCHA_PROVIDERS,
126
+ ): Promise<CaptchaApi> {
127
+ const cfg = providers[provider];
128
+ if (!cfg) return Promise.reject(new Error('Unknown captcha provider: ' + provider));
129
+
130
+ const root = globalThis as unknown as { __rozieCaptchaLoaders?: LoaderCache };
131
+ const cache = (root.__rozieCaptchaLoaders ||= {});
132
+ if (cache[provider]) return cache[provider];
133
+
134
+ const win = globalThis as unknown as Record<string, unknown>;
135
+ // A resolved global is "ready" once it exposes EITHER an explicit-render
136
+ // `render()` (recaptcha/hcaptcha/turnstile) OR FC's `createWidget()`. The
137
+ // `adapt` hook (when present) maps whichever shape arrived onto CaptchaApi.
138
+ const ready = (g: unknown): boolean => {
139
+ if (!g) return false;
140
+ const o = g as { render?: unknown; createWidget?: unknown };
141
+ return typeof o.render === 'function' || typeof o.createWidget === 'function';
142
+ };
143
+ // Resolve = the raw global, run through `adapt` when the provider supplies one.
144
+ const resolveApi = (g: unknown): CaptchaApi => (cfg.adapt ? cfg.adapt(g) : (g as CaptchaApi));
145
+
146
+ const p = new Promise<CaptchaApi>((resolve, reject) => {
147
+ if (ready(win[cfg.global])) {
148
+ resolve(resolveApi(win[cfg.global]));
149
+ return;
150
+ }
151
+ if (!document.querySelector('script[data-rozie-captcha="' + provider + '"]')) {
152
+ const el = document.createElement('script');
153
+ el.src = cfg.src;
154
+ el.async = true;
155
+ el.defer = true;
156
+ el.setAttribute('data-rozie-captcha', provider);
157
+ el.addEventListener('error', () => reject(new Error('Failed to load ' + provider + ' script')));
158
+ document.head.appendChild(el);
159
+ }
160
+ const started = Date.now();
161
+ const poll = setInterval(() => {
162
+ if (ready(win[cfg.global])) {
163
+ clearInterval(poll);
164
+ resolve(resolveApi(win[cfg.global]));
165
+ } else if (Date.now() - started > LOAD_TIMEOUT_MS) {
166
+ clearInterval(poll);
167
+ reject(new Error(provider + ' script load timeout'));
168
+ }
169
+ }, 50);
170
+ });
171
+
172
+ cache[provider] = p;
173
+ return p;
174
+ }
@@ -0,0 +1,117 @@
1
+ // loadRecaptchaV3.ts — reCAPTCHA v3 api.js loader for the RecaptchaV3 family.
2
+ //
3
+ // reCAPTCHA v3 is scoreless and widget-less: there is NO render() and no DOM
4
+ // element — the script URL carries the sitekey (`?render=SITEKEY`, distinct from
5
+ // v2's `?render=explicit`), readiness is gated on `grecaptcha.ready(cb)`, and a
6
+ // verification token comes from `grecaptcha.execute(sitekey, { action })`.
7
+ //
8
+ // Modeled on loadCaptchaApi.ts (same inject-once singleton + poll/timeout/error
9
+ // discipline, same framework-agnostic constraint — touches only `document`,
10
+ // `globalThis`, and timers), but with TWO v3-specific differences:
11
+ //
12
+ // 1. The singleton cache is keyed on the SITEKEY, not a provider name. v2 and
13
+ // v3 both install the same `grecaptcha` global, and v3 binds a sitekey at
14
+ // SCRIPT-LOAD time (it is in the URL). Keying on sitekey means two distinct
15
+ // sitekeys get two scripts + two cache entries, while the same sitekey
16
+ // twice shares one — and a v2 `<Captcha>` + a v3 `<RecaptchaV3>` on one page
17
+ // never collide on a single provider-name cache slot.
18
+ // 2. Resolution waits for `grecaptcha.ready(cb)` (not just global presence) —
19
+ // v3's execute path is only safe once `ready` has fired.
20
+ //
21
+ // codegen.mjs vendors this file (minus *.test.ts) into every leaf's
22
+ // `src/internal/`, so the `./internal/loadRecaptchaV3` import resolves verbatim
23
+ // in all six compiled outputs.
24
+
25
+ /** The v3 api.js base URL. The sitekey is appended (`render=SITEKEY`) per call. */
26
+ export const RECAPTCHA_V3_SRC = 'https://www.google.com/recaptcha/api.js?render=';
27
+
28
+ /** How long to poll for `grecaptcha` before giving up (ms). */
29
+ export const LOAD_TIMEOUT_MS = 20000;
30
+
31
+ /** The slice of the v3 `grecaptcha` global this loader drives. */
32
+ export interface RecaptchaV3Global {
33
+ ready: (cb: () => void) => void;
34
+ execute: (sitekey: string, opts: { action: string }) => Promise<string>;
35
+ }
36
+
37
+ type LoaderCache = Record<string, Promise<RecaptchaV3Global>>;
38
+
39
+ const getGrecaptcha = (): RecaptchaV3Global | undefined =>
40
+ (globalThis as unknown as { grecaptcha?: RecaptchaV3Global }).grecaptcha;
41
+
42
+ /**
43
+ * Load reCAPTCHA v3's api.js ONCE per sitekey across the whole document, shared
44
+ * by every `<RecaptchaV3>` instance via a `globalThis` singleton keyed on the
45
+ * sitekey. Resolves with the `grecaptcha` global once `grecaptcha.ready(cb)` has
46
+ * fired; rejects on a script load error or a `LOAD_TIMEOUT_MS` timeout.
47
+ *
48
+ * `srcBase` is injectable for tests (pass `''` so happy-dom does not attempt a
49
+ * real network fetch — mirrors loadCaptchaApi's injectable `providers` seam).
50
+ */
51
+ export function loadRecaptchaV3(sitekey: string, srcBase: string = RECAPTCHA_V3_SRC): Promise<RecaptchaV3Global> {
52
+ const root = globalThis as unknown as { __rozieRecaptchaV3Loaders?: LoaderCache };
53
+ const cache = (root.__rozieRecaptchaV3Loaders ||= {});
54
+ if (cache[sitekey]) return cache[sitekey];
55
+
56
+ const p = new Promise<RecaptchaV3Global>((resolve, reject) => {
57
+ let settled = false;
58
+ const ready = (g: RecaptchaV3Global) => {
59
+ // Gate on grecaptcha.ready() — v3 execute is only safe afterward.
60
+ g.ready(() => {
61
+ if (settled) return;
62
+ settled = true;
63
+ resolve(g);
64
+ });
65
+ };
66
+
67
+ const present = getGrecaptcha();
68
+ if (present && typeof present.ready === 'function') {
69
+ ready(present);
70
+ return;
71
+ }
72
+
73
+ if (!document.querySelector('script[data-rozie-recaptcha-v3="' + sitekey + '"]')) {
74
+ const el = document.createElement('script');
75
+ el.src = srcBase + encodeURIComponent(sitekey);
76
+ el.async = true;
77
+ el.defer = true;
78
+ el.setAttribute('data-rozie-recaptcha-v3', sitekey);
79
+ el.addEventListener('error', () => {
80
+ if (settled) return;
81
+ settled = true;
82
+ reject(new Error('Failed to load reCAPTCHA v3 script for sitekey: ' + sitekey));
83
+ });
84
+ document.head.appendChild(el);
85
+ }
86
+
87
+ const started = Date.now();
88
+ const poll = setInterval(() => {
89
+ const g = getGrecaptcha();
90
+ if (g && typeof g.ready === 'function') {
91
+ clearInterval(poll);
92
+ ready(g);
93
+ } else if (Date.now() - started > LOAD_TIMEOUT_MS) {
94
+ clearInterval(poll);
95
+ if (settled) return;
96
+ settled = true;
97
+ reject(new Error('reCAPTCHA v3 script load timeout for sitekey: ' + sitekey));
98
+ }
99
+ }, 50);
100
+ });
101
+
102
+ cache[sitekey] = p;
103
+ return p;
104
+ }
105
+
106
+ /**
107
+ * Run a v3 challenge for `sitekey` + `action`, returning a fresh token.
108
+ * Delegates to `grecaptcha.execute(sitekey, { action })`. Call AFTER
109
+ * `loadRecaptchaV3(sitekey)` has resolved (the component threads that order).
110
+ */
111
+ export function execute(sitekey: string, opts: { action: string }): Promise<string> {
112
+ const g = getGrecaptcha();
113
+ if (!g || typeof g.execute !== 'function') {
114
+ return Promise.reject(new Error('reCAPTCHA v3 not loaded for sitekey: ' + sitekey));
115
+ }
116
+ return g.execute(sitekey, opts);
117
+ }