@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.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/dist/index.cjs +359 -0
- package/dist/index.d.cts +41 -0
- package/dist/index.d.mts +41 -0
- package/dist/index.mjs +353 -0
- package/package.json +73 -0
- package/src/Captcha.d.ts +27 -0
- package/src/Captcha.tsx +141 -0
- package/src/RecaptchaV3.d.ts +21 -0
- package/src/RecaptchaV3.tsx +109 -0
- package/src/index.ts +9 -0
- package/src/internal/loadCaptchaApi.ts +174 -0
- package/src/internal/loadRecaptchaV3.ts +117 -0
|
@@ -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
|
+
}
|