@silverassist/recaptcha 0.1.0 → 0.2.1
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/CHANGELOG.md +40 -0
- package/README.md +209 -0
- package/dist/client/index.d.mts +58 -5
- package/dist/client/index.d.ts +58 -5
- package/dist/client/index.js +162 -21
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +164 -23
- package/dist/client/index.mjs.map +1 -1
- package/dist/constants/index.d.mts +7 -4
- package/dist/constants/index.d.ts +7 -4
- package/dist/constants/index.js +10 -0
- package/dist/constants/index.js.map +1 -1
- package/dist/constants/index.mjs +10 -0
- package/dist/constants/index.mjs.map +1 -1
- package/dist/index.d.mts +23 -3
- package/dist/index.d.ts +23 -3
- package/dist/index.js +208 -21
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +210 -23
- package/dist/index.mjs.map +1 -1
- package/dist/server/index.d.mts +8 -5
- package/dist/server/index.d.ts +8 -5
- package/dist/server/index.js +21 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +21 -0
- package/dist/server/index.mjs.map +1 -1
- package/dist/types/index.d.mts +42 -6
- package/dist/types/index.d.ts +42 -6
- package/package.json +3 -2
package/dist/client/index.js
CHANGED
|
@@ -17,6 +17,39 @@ var RECAPTCHA_CONFIG = {
|
|
|
17
17
|
/** Default token refresh interval */
|
|
18
18
|
tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL
|
|
19
19
|
};
|
|
20
|
+
function loadRecaptchaScript(siteKey, onLoad, onError) {
|
|
21
|
+
if (typeof window !== "undefined" && window.__recaptchaLoaded) {
|
|
22
|
+
onLoad();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (typeof window !== "undefined" && window.__recaptchaLoading) {
|
|
26
|
+
window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
|
|
27
|
+
window.__recaptchaCallbacks.push({ onLoad, onError });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (typeof window !== "undefined") {
|
|
31
|
+
window.__recaptchaLoading = true;
|
|
32
|
+
window.__recaptchaCallbacks = [];
|
|
33
|
+
const script = document.createElement("script");
|
|
34
|
+
script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
|
|
35
|
+
script.async = true;
|
|
36
|
+
script.onload = () => {
|
|
37
|
+
window.__recaptchaLoaded = true;
|
|
38
|
+
window.__recaptchaLoading = false;
|
|
39
|
+
onLoad();
|
|
40
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
|
|
41
|
+
window.__recaptchaCallbacks = [];
|
|
42
|
+
};
|
|
43
|
+
script.onerror = () => {
|
|
44
|
+
window.__recaptchaLoading = false;
|
|
45
|
+
const error = new Error("Failed to load reCAPTCHA script");
|
|
46
|
+
onError(error);
|
|
47
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
|
|
48
|
+
window.__recaptchaCallbacks = [];
|
|
49
|
+
};
|
|
50
|
+
document.head.appendChild(script);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
20
53
|
function RecaptchaWrapper({
|
|
21
54
|
action,
|
|
22
55
|
inputName = "recaptchaToken",
|
|
@@ -24,34 +57,60 @@ function RecaptchaWrapper({
|
|
|
24
57
|
siteKey: propSiteKey,
|
|
25
58
|
refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,
|
|
26
59
|
onTokenGenerated,
|
|
27
|
-
onError
|
|
60
|
+
onError,
|
|
61
|
+
lazy = false,
|
|
62
|
+
lazyRootMargin = "200px"
|
|
28
63
|
}) {
|
|
29
64
|
const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
|
|
30
65
|
const tokenInputRef = react.useRef(null);
|
|
31
66
|
const refreshIntervalRef = react.useRef(null);
|
|
67
|
+
const containerRef = react.useRef(null);
|
|
68
|
+
const isMountedRef = react.useRef(true);
|
|
69
|
+
const [isVisible, setIsVisible] = react.useState(!lazy);
|
|
70
|
+
const [scriptLoaded, setScriptLoaded] = react.useState(false);
|
|
32
71
|
const executeRecaptcha = react.useCallback(async () => {
|
|
33
72
|
if (!siteKey) {
|
|
34
73
|
return;
|
|
35
74
|
}
|
|
36
75
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (tokenInputRef.current) {
|
|
42
|
-
tokenInputRef.current.value = token;
|
|
43
|
-
}
|
|
44
|
-
if (onTokenGenerated) {
|
|
45
|
-
onTokenGenerated(token);
|
|
46
|
-
}
|
|
47
|
-
} catch (error) {
|
|
48
|
-
console.error("[reCAPTCHA] Error executing reCAPTCHA:", error);
|
|
49
|
-
if (onError && error instanceof Error) {
|
|
50
|
-
onError(error);
|
|
51
|
-
}
|
|
76
|
+
const waitForGrecaptcha = async (maxAttempts = 20, delayMs = 100) => {
|
|
77
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
78
|
+
if (!isMountedRef.current) {
|
|
79
|
+
return false;
|
|
52
80
|
}
|
|
53
|
-
|
|
81
|
+
if (typeof window !== "undefined" && window.grecaptcha) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
const grecaptchaAvailable = await waitForGrecaptcha();
|
|
89
|
+
if (!isMountedRef.current || !grecaptchaAvailable) {
|
|
90
|
+
return;
|
|
54
91
|
}
|
|
92
|
+
window.grecaptcha.ready(async () => {
|
|
93
|
+
if (!isMountedRef.current) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const token = await window.grecaptcha.execute(siteKey, { action });
|
|
98
|
+
if (!isMountedRef.current) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (tokenInputRef.current) {
|
|
102
|
+
tokenInputRef.current.value = token;
|
|
103
|
+
}
|
|
104
|
+
if (onTokenGenerated) {
|
|
105
|
+
onTokenGenerated(token);
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error("[reCAPTCHA] Error executing reCAPTCHA:", error);
|
|
109
|
+
if (onError && error instanceof Error) {
|
|
110
|
+
onError(error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
55
114
|
} catch (error) {
|
|
56
115
|
console.error("[reCAPTCHA] Error:", error);
|
|
57
116
|
if (onError && error instanceof Error) {
|
|
@@ -60,7 +119,49 @@ function RecaptchaWrapper({
|
|
|
60
119
|
}
|
|
61
120
|
}, [siteKey, action, onTokenGenerated, onError]);
|
|
62
121
|
react.useEffect(() => {
|
|
63
|
-
|
|
122
|
+
if (!lazy || !containerRef.current) return;
|
|
123
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
124
|
+
setIsVisible(true);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const observer = new IntersectionObserver(
|
|
128
|
+
([entry]) => {
|
|
129
|
+
if (entry.isIntersecting) {
|
|
130
|
+
setIsVisible(true);
|
|
131
|
+
observer.disconnect();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{ rootMargin: lazyRootMargin }
|
|
135
|
+
);
|
|
136
|
+
observer.observe(containerRef.current);
|
|
137
|
+
return () => observer.disconnect();
|
|
138
|
+
}, [lazy, lazyRootMargin]);
|
|
139
|
+
react.useEffect(() => {
|
|
140
|
+
if (!siteKey) return;
|
|
141
|
+
if (lazy) return;
|
|
142
|
+
if (typeof window !== "undefined" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {
|
|
143
|
+
window.__recaptchaLoading = true;
|
|
144
|
+
window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
|
|
145
|
+
}
|
|
146
|
+
}, [siteKey, lazy]);
|
|
147
|
+
react.useEffect(() => {
|
|
148
|
+
if (!siteKey) return;
|
|
149
|
+
if (!lazy) return;
|
|
150
|
+
if (!isVisible) return;
|
|
151
|
+
const handleLoad = () => {
|
|
152
|
+
setScriptLoaded(true);
|
|
153
|
+
executeRecaptcha();
|
|
154
|
+
};
|
|
155
|
+
const handleError = (error) => {
|
|
156
|
+
console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
|
|
157
|
+
if (onError) {
|
|
158
|
+
onError(error);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
loadRecaptchaScript(siteKey, handleLoad, handleError);
|
|
162
|
+
}, [siteKey, lazy, isVisible, executeRecaptcha, onError]);
|
|
163
|
+
react.useEffect(() => {
|
|
164
|
+
if (!scriptLoaded) return;
|
|
64
165
|
refreshIntervalRef.current = setInterval(() => {
|
|
65
166
|
executeRecaptcha();
|
|
66
167
|
}, refreshInterval);
|
|
@@ -69,7 +170,13 @@ function RecaptchaWrapper({
|
|
|
69
170
|
clearInterval(refreshIntervalRef.current);
|
|
70
171
|
}
|
|
71
172
|
};
|
|
72
|
-
}, [executeRecaptcha, refreshInterval]);
|
|
173
|
+
}, [scriptLoaded, executeRecaptcha, refreshInterval]);
|
|
174
|
+
react.useEffect(() => {
|
|
175
|
+
isMountedRef.current = true;
|
|
176
|
+
return () => {
|
|
177
|
+
isMountedRef.current = false;
|
|
178
|
+
};
|
|
179
|
+
}, []);
|
|
73
180
|
if (!siteKey) {
|
|
74
181
|
if (process.env.NODE_ENV === "development") {
|
|
75
182
|
console.warn(
|
|
@@ -78,7 +185,7 @@ function RecaptchaWrapper({
|
|
|
78
185
|
}
|
|
79
186
|
return null;
|
|
80
187
|
}
|
|
81
|
-
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
188
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, style: { display: "contents" }, children: [
|
|
82
189
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
83
190
|
"input",
|
|
84
191
|
{
|
|
@@ -89,15 +196,28 @@ function RecaptchaWrapper({
|
|
|
89
196
|
"data-testid": "recaptcha-token-input"
|
|
90
197
|
}
|
|
91
198
|
),
|
|
92
|
-
/* @__PURE__ */ jsxRuntime.jsx(
|
|
199
|
+
!lazy && /* @__PURE__ */ jsxRuntime.jsx(
|
|
93
200
|
Script__default.default,
|
|
94
201
|
{
|
|
95
202
|
src: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
|
|
96
203
|
strategy: "afterInteractive",
|
|
97
204
|
onLoad: () => {
|
|
205
|
+
if (typeof window !== "undefined") {
|
|
206
|
+
window.__recaptchaLoaded = true;
|
|
207
|
+
window.__recaptchaLoading = false;
|
|
208
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
|
|
209
|
+
window.__recaptchaCallbacks = [];
|
|
210
|
+
}
|
|
211
|
+
setScriptLoaded(true);
|
|
98
212
|
executeRecaptcha();
|
|
99
213
|
},
|
|
100
214
|
onError: () => {
|
|
215
|
+
if (typeof window !== "undefined") {
|
|
216
|
+
window.__recaptchaLoading = false;
|
|
217
|
+
const error = new Error("Failed to load reCAPTCHA script");
|
|
218
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
|
|
219
|
+
window.__recaptchaCallbacks = [];
|
|
220
|
+
}
|
|
101
221
|
console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
|
|
102
222
|
if (onError) {
|
|
103
223
|
onError(new Error("Failed to load reCAPTCHA script"));
|
|
@@ -108,6 +228,27 @@ function RecaptchaWrapper({
|
|
|
108
228
|
] });
|
|
109
229
|
}
|
|
110
230
|
var client_default = RecaptchaWrapper;
|
|
231
|
+
/**
|
|
232
|
+
* @module @silverassist/recaptcha/constants
|
|
233
|
+
* @description reCAPTCHA Configuration Constants - Default configuration values
|
|
234
|
+
* for reCAPTCHA v3 integration.
|
|
235
|
+
*
|
|
236
|
+
* @author Miguel Colmenares <me@miguelcolmenares.com>
|
|
237
|
+
* @license Polyform-Noncommercial-1.0.0
|
|
238
|
+
* @version 0.2.1
|
|
239
|
+
* @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}
|
|
240
|
+
*/
|
|
241
|
+
/**
|
|
242
|
+
* @module @silverassist/recaptcha/client
|
|
243
|
+
* @description reCAPTCHA v3 Client Component - Loads the Google reCAPTCHA script
|
|
244
|
+
* and generates tokens automatically. Place inside a form to add invisible spam protection.
|
|
245
|
+
*
|
|
246
|
+
* @author Miguel Colmenares <me@miguelcolmenares.com>
|
|
247
|
+
* @license Polyform-Noncommercial-1.0.0
|
|
248
|
+
* @version 0.2.1
|
|
249
|
+
* @see {@link https://developers.google.com/recaptcha/docs/v3|Google reCAPTCHA v3 Documentation}
|
|
250
|
+
* @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}
|
|
251
|
+
*/
|
|
111
252
|
|
|
112
253
|
exports.RecaptchaWrapper = RecaptchaWrapper;
|
|
113
254
|
exports.default = client_default;
|
package/dist/client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":["useRef","useCallback","useEffect","jsxs","Fragment","jsx","Script"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * reCAPTCHA Configuration Constants\n *\n * Default configuration values for reCAPTCHA v3 integration.\n *\n * @packageDocumentation\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * reCAPTCHA v3 Client Component\n *\n * Loads the Google reCAPTCHA script and generates tokens automatically.\n * Place inside a form to add invisible spam protection.\n *\n * @see https://developers.google.com/recaptcha/docs/v3\n * @packageDocumentation\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={(token) => console.log(\"Token:\", token)}\n * onError={(error) => console.error(\"Error:\", error)}\n * />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n if (typeof window !== \"undefined\" && window.grecaptcha) {\n window.grecaptcha.ready(async () => {\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Generate token immediately when component mounts\n executeRecaptcha();\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [executeRecaptcha, refreshInterval]);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <>\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script */}\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n executeRecaptcha();\n }}\n onError={() => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n </>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":["useRef","useState","useCallback","useEffect","jsxs","jsx","Script"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @module @silverassist/recaptcha/constants\n * @description reCAPTCHA Configuration Constants - Default configuration values\n * for reCAPTCHA v3 integration.\n *\n * @author Miguel Colmenares <me@miguelcolmenares.com>\n * @license Polyform-Noncommercial-1.0.0\n * @version 0.2.1\n * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * @module @silverassist/recaptcha/client\n * @description reCAPTCHA v3 Client Component - Loads the Google reCAPTCHA script\n * and generates tokens automatically. Place inside a form to add invisible spam protection.\n *\n * @author Miguel Colmenares <me@miguelcolmenares.com>\n * @license Polyform-Noncommercial-1.0.0\n * @version 0.2.1\n * @see {@link https://developers.google.com/recaptcha/docs/v3|Google reCAPTCHA v3 Documentation}\n * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * Load reCAPTCHA script manually (singleton pattern)\n * Ensures script is only loaded once globally\n */\nfunction loadRecaptchaScript(\n siteKey: string,\n onLoad: () => void,\n onError: (error: Error) => void\n): void {\n // Already loaded\n if (typeof window !== \"undefined\" && window.__recaptchaLoaded) {\n onLoad();\n return;\n }\n\n // Currently loading - add callbacks\n if (typeof window !== \"undefined\" && window.__recaptchaLoading) {\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n window.__recaptchaCallbacks.push({ onLoad, onError });\n return;\n }\n\n // Start loading\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = [];\n\n const script = document.createElement(\"script\");\n script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;\n script.async = true;\n\n script.onload = () => {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n onLoad();\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n };\n\n script.onerror = () => {\n window.__recaptchaLoading = false;\n const error = new Error(\"Failed to load reCAPTCHA script\");\n onError(error);\n // Notify all queued callbacks about the failure\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n };\n\n document.head.appendChild(script);\n }\n}\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n * - Lazy loading support to defer script loading until visible\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * // IMPORTANT: Memoize callbacks to prevent unnecessary re-renders\n * const handleToken = useCallback((token: string) => {\n * console.log(\"Token:\", token);\n * }, []);\n *\n * const handleError = useCallback((error: Error) => {\n * console.error(\"Error:\", error);\n * }, []);\n *\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={handleToken}\n * onError={handleError}\n * />\n * ```\n *\n * @example Lazy loading for better performance\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy />\n * ```\n *\n * @example Lazy loading with custom root margin\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy lazyRootMargin=\"400px\" />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n lazy = false,\n lazyRootMargin = \"200px\",\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n const containerRef = useRef<HTMLDivElement>(null);\n const isMountedRef = useRef<boolean>(true);\n const [isVisible, setIsVisible] = useState(!lazy); // If not lazy, start visible\n const [scriptLoaded, setScriptLoaded] = useState(false);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n // Wait for grecaptcha to be available (with timeout)\n // This handles the race condition where the script loads but\n // window.grecaptcha is not immediately available\n const waitForGrecaptcha = async (maxAttempts = 20, delayMs = 100): Promise<boolean> => {\n for (let i = 0; i < maxAttempts; i++) {\n // Check if component is still mounted\n if (!isMountedRef.current) {\n return false;\n }\n \n if (typeof window !== \"undefined\" && window.grecaptcha) {\n return true;\n }\n await new Promise((resolve) => setTimeout(resolve, delayMs));\n }\n return false;\n };\n\n const grecaptchaAvailable = await waitForGrecaptcha();\n\n // Exit early if component unmounted during polling\n if (!isMountedRef.current || !grecaptchaAvailable) {\n return;\n }\n\n window.grecaptcha.ready(async () => {\n // Check if still mounted before executing\n if (!isMountedRef.current) {\n return;\n }\n\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Check if still mounted before storing token\n if (!isMountedRef.current) {\n return;\n }\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // IntersectionObserver for lazy loading\n useEffect(() => {\n if (!lazy || !containerRef.current) return;\n\n // Fallback to eager loading if IntersectionObserver is not supported\n // (older browsers, some SSR/test environments)\n if (typeof IntersectionObserver === \"undefined\") {\n setIsVisible(true);\n return;\n }\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n setIsVisible(true);\n observer.disconnect();\n }\n },\n { rootMargin: lazyRootMargin }\n );\n\n observer.observe(containerRef.current);\n return () => observer.disconnect();\n }, [lazy, lazyRootMargin]);\n\n // Mark loading flag for non-lazy mode to prevent duplicate loads\n useEffect(() => {\n if (!siteKey) return;\n if (lazy) return; // Only for non-lazy mode\n\n // Set loading flag before Script component loads\n if (typeof window !== \"undefined\" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n }\n }, [siteKey, lazy]);\n\n // Load script when visible (only for lazy mode)\n useEffect(() => {\n if (!siteKey) return;\n if (!lazy) return; // Only use manual loading for lazy mode\n if (!isVisible) return; // Wait until visible\n\n const handleLoad = () => {\n setScriptLoaded(true);\n executeRecaptcha();\n };\n\n const handleError = (error: Error) => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(error);\n }\n };\n\n loadRecaptchaScript(siteKey, handleLoad, handleError);\n }, [siteKey, lazy, isVisible, executeRecaptcha, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Only set up refresh if script is loaded\n if (!scriptLoaded) return;\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [scriptLoaded, executeRecaptcha, refreshInterval]);\n\n // Track mounted state to prevent side effects after unmount\n useEffect(() => {\n isMountedRef.current = true;\n \n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <div ref={containerRef} style={{ display: \"contents\" }}>\n {/* \n Note: display: contents makes this wrapper transparent to the DOM layout.\n The wrapper is needed for IntersectionObserver but shouldn't affect form layout.\n Browser support: https://caniuse.com/css-display-contents\n */}\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script using Next.js Script component for non-lazy mode */}\n {!lazy && (\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n // Mark script as loaded globally for singleton behavior\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n // Flush all queued callbacks from lazy instances\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n }\n setScriptLoaded(true);\n executeRecaptcha();\n }}\n onError={() => {\n // Mark loading as complete on error\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = false;\n // Notify all queued callbacks about the failure\n const error = new Error(\"Failed to load reCAPTCHA script\");\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n }\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n )}\n </div>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}
|
package/dist/client/index.mjs
CHANGED
|
@@ -1,14 +1,47 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Script from 'next/script';
|
|
4
|
-
import { useRef, useCallback, useEffect } from 'react';
|
|
5
|
-
import { jsxs,
|
|
4
|
+
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
5
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
6
6
|
|
|
7
7
|
var DEFAULT_TOKEN_REFRESH_INTERVAL = 9e4;
|
|
8
8
|
var RECAPTCHA_CONFIG = {
|
|
9
9
|
/** Default token refresh interval */
|
|
10
10
|
tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL
|
|
11
11
|
};
|
|
12
|
+
function loadRecaptchaScript(siteKey, onLoad, onError) {
|
|
13
|
+
if (typeof window !== "undefined" && window.__recaptchaLoaded) {
|
|
14
|
+
onLoad();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (typeof window !== "undefined" && window.__recaptchaLoading) {
|
|
18
|
+
window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
|
|
19
|
+
window.__recaptchaCallbacks.push({ onLoad, onError });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (typeof window !== "undefined") {
|
|
23
|
+
window.__recaptchaLoading = true;
|
|
24
|
+
window.__recaptchaCallbacks = [];
|
|
25
|
+
const script = document.createElement("script");
|
|
26
|
+
script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
|
|
27
|
+
script.async = true;
|
|
28
|
+
script.onload = () => {
|
|
29
|
+
window.__recaptchaLoaded = true;
|
|
30
|
+
window.__recaptchaLoading = false;
|
|
31
|
+
onLoad();
|
|
32
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
|
|
33
|
+
window.__recaptchaCallbacks = [];
|
|
34
|
+
};
|
|
35
|
+
script.onerror = () => {
|
|
36
|
+
window.__recaptchaLoading = false;
|
|
37
|
+
const error = new Error("Failed to load reCAPTCHA script");
|
|
38
|
+
onError(error);
|
|
39
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
|
|
40
|
+
window.__recaptchaCallbacks = [];
|
|
41
|
+
};
|
|
42
|
+
document.head.appendChild(script);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
12
45
|
function RecaptchaWrapper({
|
|
13
46
|
action,
|
|
14
47
|
inputName = "recaptchaToken",
|
|
@@ -16,34 +49,60 @@ function RecaptchaWrapper({
|
|
|
16
49
|
siteKey: propSiteKey,
|
|
17
50
|
refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,
|
|
18
51
|
onTokenGenerated,
|
|
19
|
-
onError
|
|
52
|
+
onError,
|
|
53
|
+
lazy = false,
|
|
54
|
+
lazyRootMargin = "200px"
|
|
20
55
|
}) {
|
|
21
56
|
const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
|
|
22
57
|
const tokenInputRef = useRef(null);
|
|
23
58
|
const refreshIntervalRef = useRef(null);
|
|
59
|
+
const containerRef = useRef(null);
|
|
60
|
+
const isMountedRef = useRef(true);
|
|
61
|
+
const [isVisible, setIsVisible] = useState(!lazy);
|
|
62
|
+
const [scriptLoaded, setScriptLoaded] = useState(false);
|
|
24
63
|
const executeRecaptcha = useCallback(async () => {
|
|
25
64
|
if (!siteKey) {
|
|
26
65
|
return;
|
|
27
66
|
}
|
|
28
67
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (tokenInputRef.current) {
|
|
34
|
-
tokenInputRef.current.value = token;
|
|
35
|
-
}
|
|
36
|
-
if (onTokenGenerated) {
|
|
37
|
-
onTokenGenerated(token);
|
|
38
|
-
}
|
|
39
|
-
} catch (error) {
|
|
40
|
-
console.error("[reCAPTCHA] Error executing reCAPTCHA:", error);
|
|
41
|
-
if (onError && error instanceof Error) {
|
|
42
|
-
onError(error);
|
|
43
|
-
}
|
|
68
|
+
const waitForGrecaptcha = async (maxAttempts = 20, delayMs = 100) => {
|
|
69
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
70
|
+
if (!isMountedRef.current) {
|
|
71
|
+
return false;
|
|
44
72
|
}
|
|
45
|
-
|
|
73
|
+
if (typeof window !== "undefined" && window.grecaptcha) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
const grecaptchaAvailable = await waitForGrecaptcha();
|
|
81
|
+
if (!isMountedRef.current || !grecaptchaAvailable) {
|
|
82
|
+
return;
|
|
46
83
|
}
|
|
84
|
+
window.grecaptcha.ready(async () => {
|
|
85
|
+
if (!isMountedRef.current) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const token = await window.grecaptcha.execute(siteKey, { action });
|
|
90
|
+
if (!isMountedRef.current) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (tokenInputRef.current) {
|
|
94
|
+
tokenInputRef.current.value = token;
|
|
95
|
+
}
|
|
96
|
+
if (onTokenGenerated) {
|
|
97
|
+
onTokenGenerated(token);
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error("[reCAPTCHA] Error executing reCAPTCHA:", error);
|
|
101
|
+
if (onError && error instanceof Error) {
|
|
102
|
+
onError(error);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
47
106
|
} catch (error) {
|
|
48
107
|
console.error("[reCAPTCHA] Error:", error);
|
|
49
108
|
if (onError && error instanceof Error) {
|
|
@@ -52,7 +111,49 @@ function RecaptchaWrapper({
|
|
|
52
111
|
}
|
|
53
112
|
}, [siteKey, action, onTokenGenerated, onError]);
|
|
54
113
|
useEffect(() => {
|
|
55
|
-
|
|
114
|
+
if (!lazy || !containerRef.current) return;
|
|
115
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
116
|
+
setIsVisible(true);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const observer = new IntersectionObserver(
|
|
120
|
+
([entry]) => {
|
|
121
|
+
if (entry.isIntersecting) {
|
|
122
|
+
setIsVisible(true);
|
|
123
|
+
observer.disconnect();
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{ rootMargin: lazyRootMargin }
|
|
127
|
+
);
|
|
128
|
+
observer.observe(containerRef.current);
|
|
129
|
+
return () => observer.disconnect();
|
|
130
|
+
}, [lazy, lazyRootMargin]);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!siteKey) return;
|
|
133
|
+
if (lazy) return;
|
|
134
|
+
if (typeof window !== "undefined" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {
|
|
135
|
+
window.__recaptchaLoading = true;
|
|
136
|
+
window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
|
|
137
|
+
}
|
|
138
|
+
}, [siteKey, lazy]);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!siteKey) return;
|
|
141
|
+
if (!lazy) return;
|
|
142
|
+
if (!isVisible) return;
|
|
143
|
+
const handleLoad = () => {
|
|
144
|
+
setScriptLoaded(true);
|
|
145
|
+
executeRecaptcha();
|
|
146
|
+
};
|
|
147
|
+
const handleError = (error) => {
|
|
148
|
+
console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
|
|
149
|
+
if (onError) {
|
|
150
|
+
onError(error);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
loadRecaptchaScript(siteKey, handleLoad, handleError);
|
|
154
|
+
}, [siteKey, lazy, isVisible, executeRecaptcha, onError]);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!scriptLoaded) return;
|
|
56
157
|
refreshIntervalRef.current = setInterval(() => {
|
|
57
158
|
executeRecaptcha();
|
|
58
159
|
}, refreshInterval);
|
|
@@ -61,7 +162,13 @@ function RecaptchaWrapper({
|
|
|
61
162
|
clearInterval(refreshIntervalRef.current);
|
|
62
163
|
}
|
|
63
164
|
};
|
|
64
|
-
}, [executeRecaptcha, refreshInterval]);
|
|
165
|
+
}, [scriptLoaded, executeRecaptcha, refreshInterval]);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
isMountedRef.current = true;
|
|
168
|
+
return () => {
|
|
169
|
+
isMountedRef.current = false;
|
|
170
|
+
};
|
|
171
|
+
}, []);
|
|
65
172
|
if (!siteKey) {
|
|
66
173
|
if (process.env.NODE_ENV === "development") {
|
|
67
174
|
console.warn(
|
|
@@ -70,7 +177,7 @@ function RecaptchaWrapper({
|
|
|
70
177
|
}
|
|
71
178
|
return null;
|
|
72
179
|
}
|
|
73
|
-
return /* @__PURE__ */ jsxs(
|
|
180
|
+
return /* @__PURE__ */ jsxs("div", { ref: containerRef, style: { display: "contents" }, children: [
|
|
74
181
|
/* @__PURE__ */ jsx(
|
|
75
182
|
"input",
|
|
76
183
|
{
|
|
@@ -81,15 +188,28 @@ function RecaptchaWrapper({
|
|
|
81
188
|
"data-testid": "recaptcha-token-input"
|
|
82
189
|
}
|
|
83
190
|
),
|
|
84
|
-
/* @__PURE__ */ jsx(
|
|
191
|
+
!lazy && /* @__PURE__ */ jsx(
|
|
85
192
|
Script,
|
|
86
193
|
{
|
|
87
194
|
src: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
|
|
88
195
|
strategy: "afterInteractive",
|
|
89
196
|
onLoad: () => {
|
|
197
|
+
if (typeof window !== "undefined") {
|
|
198
|
+
window.__recaptchaLoaded = true;
|
|
199
|
+
window.__recaptchaLoading = false;
|
|
200
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
|
|
201
|
+
window.__recaptchaCallbacks = [];
|
|
202
|
+
}
|
|
203
|
+
setScriptLoaded(true);
|
|
90
204
|
executeRecaptcha();
|
|
91
205
|
},
|
|
92
206
|
onError: () => {
|
|
207
|
+
if (typeof window !== "undefined") {
|
|
208
|
+
window.__recaptchaLoading = false;
|
|
209
|
+
const error = new Error("Failed to load reCAPTCHA script");
|
|
210
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
|
|
211
|
+
window.__recaptchaCallbacks = [];
|
|
212
|
+
}
|
|
93
213
|
console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
|
|
94
214
|
if (onError) {
|
|
95
215
|
onError(new Error("Failed to load reCAPTCHA script"));
|
|
@@ -100,6 +220,27 @@ function RecaptchaWrapper({
|
|
|
100
220
|
] });
|
|
101
221
|
}
|
|
102
222
|
var client_default = RecaptchaWrapper;
|
|
223
|
+
/**
|
|
224
|
+
* @module @silverassist/recaptcha/constants
|
|
225
|
+
* @description reCAPTCHA Configuration Constants - Default configuration values
|
|
226
|
+
* for reCAPTCHA v3 integration.
|
|
227
|
+
*
|
|
228
|
+
* @author Miguel Colmenares <me@miguelcolmenares.com>
|
|
229
|
+
* @license Polyform-Noncommercial-1.0.0
|
|
230
|
+
* @version 0.2.1
|
|
231
|
+
* @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}
|
|
232
|
+
*/
|
|
233
|
+
/**
|
|
234
|
+
* @module @silverassist/recaptcha/client
|
|
235
|
+
* @description reCAPTCHA v3 Client Component - Loads the Google reCAPTCHA script
|
|
236
|
+
* and generates tokens automatically. Place inside a form to add invisible spam protection.
|
|
237
|
+
*
|
|
238
|
+
* @author Miguel Colmenares <me@miguelcolmenares.com>
|
|
239
|
+
* @license Polyform-Noncommercial-1.0.0
|
|
240
|
+
* @version 0.2.1
|
|
241
|
+
* @see {@link https://developers.google.com/recaptcha/docs/v3|Google reCAPTCHA v3 Documentation}
|
|
242
|
+
* @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}
|
|
243
|
+
*/
|
|
103
244
|
|
|
104
245
|
export { RecaptchaWrapper, client_default as default };
|
|
105
246
|
//# sourceMappingURL=index.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * reCAPTCHA Configuration Constants\n *\n * Default configuration values for reCAPTCHA v3 integration.\n *\n * @packageDocumentation\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * reCAPTCHA v3 Client Component\n *\n * Loads the Google reCAPTCHA script and generates tokens automatically.\n * Place inside a form to add invisible spam protection.\n *\n * @see https://developers.google.com/recaptcha/docs/v3\n * @packageDocumentation\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={(token) => console.log(\"Token:\", token)}\n * onError={(error) => console.error(\"Error:\", error)}\n * />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n if (typeof window !== \"undefined\" && window.grecaptcha) {\n window.grecaptcha.ready(async () => {\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Generate token immediately when component mounts\n executeRecaptcha();\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [executeRecaptcha, refreshInterval]);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <>\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script */}\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n executeRecaptcha();\n }}\n onError={() => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n </>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * @module @silverassist/recaptcha/constants\n * @description reCAPTCHA Configuration Constants - Default configuration values\n * for reCAPTCHA v3 integration.\n *\n * @author Miguel Colmenares <me@miguelcolmenares.com>\n * @license Polyform-Noncommercial-1.0.0\n * @version 0.2.1\n * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * @module @silverassist/recaptcha/client\n * @description reCAPTCHA v3 Client Component - Loads the Google reCAPTCHA script\n * and generates tokens automatically. Place inside a form to add invisible spam protection.\n *\n * @author Miguel Colmenares <me@miguelcolmenares.com>\n * @license Polyform-Noncommercial-1.0.0\n * @version 0.2.1\n * @see {@link https://developers.google.com/recaptcha/docs/v3|Google reCAPTCHA v3 Documentation}\n * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * Load reCAPTCHA script manually (singleton pattern)\n * Ensures script is only loaded once globally\n */\nfunction loadRecaptchaScript(\n siteKey: string,\n onLoad: () => void,\n onError: (error: Error) => void\n): void {\n // Already loaded\n if (typeof window !== \"undefined\" && window.__recaptchaLoaded) {\n onLoad();\n return;\n }\n\n // Currently loading - add callbacks\n if (typeof window !== \"undefined\" && window.__recaptchaLoading) {\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n window.__recaptchaCallbacks.push({ onLoad, onError });\n return;\n }\n\n // Start loading\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = [];\n\n const script = document.createElement(\"script\");\n script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;\n script.async = true;\n\n script.onload = () => {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n onLoad();\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n };\n\n script.onerror = () => {\n window.__recaptchaLoading = false;\n const error = new Error(\"Failed to load reCAPTCHA script\");\n onError(error);\n // Notify all queued callbacks about the failure\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n };\n\n document.head.appendChild(script);\n }\n}\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n * - Lazy loading support to defer script loading until visible\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * // IMPORTANT: Memoize callbacks to prevent unnecessary re-renders\n * const handleToken = useCallback((token: string) => {\n * console.log(\"Token:\", token);\n * }, []);\n *\n * const handleError = useCallback((error: Error) => {\n * console.error(\"Error:\", error);\n * }, []);\n *\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={handleToken}\n * onError={handleError}\n * />\n * ```\n *\n * @example Lazy loading for better performance\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy />\n * ```\n *\n * @example Lazy loading with custom root margin\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy lazyRootMargin=\"400px\" />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n lazy = false,\n lazyRootMargin = \"200px\",\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n const containerRef = useRef<HTMLDivElement>(null);\n const isMountedRef = useRef<boolean>(true);\n const [isVisible, setIsVisible] = useState(!lazy); // If not lazy, start visible\n const [scriptLoaded, setScriptLoaded] = useState(false);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n // Wait for grecaptcha to be available (with timeout)\n // This handles the race condition where the script loads but\n // window.grecaptcha is not immediately available\n const waitForGrecaptcha = async (maxAttempts = 20, delayMs = 100): Promise<boolean> => {\n for (let i = 0; i < maxAttempts; i++) {\n // Check if component is still mounted\n if (!isMountedRef.current) {\n return false;\n }\n \n if (typeof window !== \"undefined\" && window.grecaptcha) {\n return true;\n }\n await new Promise((resolve) => setTimeout(resolve, delayMs));\n }\n return false;\n };\n\n const grecaptchaAvailable = await waitForGrecaptcha();\n\n // Exit early if component unmounted during polling\n if (!isMountedRef.current || !grecaptchaAvailable) {\n return;\n }\n\n window.grecaptcha.ready(async () => {\n // Check if still mounted before executing\n if (!isMountedRef.current) {\n return;\n }\n\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Check if still mounted before storing token\n if (!isMountedRef.current) {\n return;\n }\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // IntersectionObserver for lazy loading\n useEffect(() => {\n if (!lazy || !containerRef.current) return;\n\n // Fallback to eager loading if IntersectionObserver is not supported\n // (older browsers, some SSR/test environments)\n if (typeof IntersectionObserver === \"undefined\") {\n setIsVisible(true);\n return;\n }\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n setIsVisible(true);\n observer.disconnect();\n }\n },\n { rootMargin: lazyRootMargin }\n );\n\n observer.observe(containerRef.current);\n return () => observer.disconnect();\n }, [lazy, lazyRootMargin]);\n\n // Mark loading flag for non-lazy mode to prevent duplicate loads\n useEffect(() => {\n if (!siteKey) return;\n if (lazy) return; // Only for non-lazy mode\n\n // Set loading flag before Script component loads\n if (typeof window !== \"undefined\" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n }\n }, [siteKey, lazy]);\n\n // Load script when visible (only for lazy mode)\n useEffect(() => {\n if (!siteKey) return;\n if (!lazy) return; // Only use manual loading for lazy mode\n if (!isVisible) return; // Wait until visible\n\n const handleLoad = () => {\n setScriptLoaded(true);\n executeRecaptcha();\n };\n\n const handleError = (error: Error) => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(error);\n }\n };\n\n loadRecaptchaScript(siteKey, handleLoad, handleError);\n }, [siteKey, lazy, isVisible, executeRecaptcha, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Only set up refresh if script is loaded\n if (!scriptLoaded) return;\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [scriptLoaded, executeRecaptcha, refreshInterval]);\n\n // Track mounted state to prevent side effects after unmount\n useEffect(() => {\n isMountedRef.current = true;\n \n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <div ref={containerRef} style={{ display: \"contents\" }}>\n {/* \n Note: display: contents makes this wrapper transparent to the DOM layout.\n The wrapper is needed for IntersectionObserver but shouldn't affect form layout.\n Browser support: https://caniuse.com/css-display-contents\n */}\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script using Next.js Script component for non-lazy mode */}\n {!lazy && (\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n // Mark script as loaded globally for singleton behavior\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n // Flush all queued callbacks from lazy instances\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n }\n setScriptLoaded(true);\n executeRecaptcha();\n }}\n onError={() => {\n // Mark loading as complete on error\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = false;\n // Notify all queued callbacks about the failure\n const error = new Error(\"Failed to load reCAPTCHA script\");\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n }\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n )}\n </div>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}
|