@silverassist/recaptcha 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import Script from 'next/script';
2
- import { useRef, useCallback, useEffect } from 'react';
3
- import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
2
+ import { useRef, useState, useCallback, useEffect } from 'react';
3
+ import { jsxs, jsx } from 'react/jsx-runtime';
4
4
 
5
5
  // src/client/index.tsx
6
6
 
@@ -15,6 +15,39 @@ var RECAPTCHA_CONFIG = {
15
15
  /** Default token refresh interval */
16
16
  tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL
17
17
  };
18
+ function loadRecaptchaScript(siteKey, onLoad, onError) {
19
+ if (typeof window !== "undefined" && window.__recaptchaLoaded) {
20
+ onLoad();
21
+ return;
22
+ }
23
+ if (typeof window !== "undefined" && window.__recaptchaLoading) {
24
+ window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
25
+ window.__recaptchaCallbacks.push({ onLoad, onError });
26
+ return;
27
+ }
28
+ if (typeof window !== "undefined") {
29
+ window.__recaptchaLoading = true;
30
+ window.__recaptchaCallbacks = [];
31
+ const script = document.createElement("script");
32
+ script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
33
+ script.async = true;
34
+ script.onload = () => {
35
+ window.__recaptchaLoaded = true;
36
+ window.__recaptchaLoading = false;
37
+ onLoad();
38
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
39
+ window.__recaptchaCallbacks = [];
40
+ };
41
+ script.onerror = () => {
42
+ window.__recaptchaLoading = false;
43
+ const error = new Error("Failed to load reCAPTCHA script");
44
+ onError(error);
45
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
46
+ window.__recaptchaCallbacks = [];
47
+ };
48
+ document.head.appendChild(script);
49
+ }
50
+ }
18
51
  function RecaptchaWrapper({
19
52
  action,
20
53
  inputName = "recaptchaToken",
@@ -22,11 +55,16 @@ function RecaptchaWrapper({
22
55
  siteKey: propSiteKey,
23
56
  refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,
24
57
  onTokenGenerated,
25
- onError
58
+ onError,
59
+ lazy = false,
60
+ lazyRootMargin = "200px"
26
61
  }) {
27
62
  const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
28
63
  const tokenInputRef = useRef(null);
29
64
  const refreshIntervalRef = useRef(null);
65
+ const containerRef = useRef(null);
66
+ const [isVisible, setIsVisible] = useState(!lazy);
67
+ const [scriptLoaded, setScriptLoaded] = useState(false);
30
68
  const executeRecaptcha = useCallback(async () => {
31
69
  if (!siteKey) {
32
70
  return;
@@ -58,7 +96,49 @@ function RecaptchaWrapper({
58
96
  }
59
97
  }, [siteKey, action, onTokenGenerated, onError]);
60
98
  useEffect(() => {
61
- executeRecaptcha();
99
+ if (!lazy || !containerRef.current) return;
100
+ if (typeof IntersectionObserver === "undefined") {
101
+ setIsVisible(true);
102
+ return;
103
+ }
104
+ const observer = new IntersectionObserver(
105
+ ([entry]) => {
106
+ if (entry.isIntersecting) {
107
+ setIsVisible(true);
108
+ observer.disconnect();
109
+ }
110
+ },
111
+ { rootMargin: lazyRootMargin }
112
+ );
113
+ observer.observe(containerRef.current);
114
+ return () => observer.disconnect();
115
+ }, [lazy, lazyRootMargin]);
116
+ useEffect(() => {
117
+ if (!siteKey) return;
118
+ if (lazy) return;
119
+ if (typeof window !== "undefined" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {
120
+ window.__recaptchaLoading = true;
121
+ window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
122
+ }
123
+ }, [siteKey, lazy]);
124
+ useEffect(() => {
125
+ if (!siteKey) return;
126
+ if (!lazy) return;
127
+ if (!isVisible) return;
128
+ const handleLoad = () => {
129
+ setScriptLoaded(true);
130
+ executeRecaptcha();
131
+ };
132
+ const handleError = (error) => {
133
+ console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
134
+ if (onError) {
135
+ onError(error);
136
+ }
137
+ };
138
+ loadRecaptchaScript(siteKey, handleLoad, handleError);
139
+ }, [siteKey, lazy, isVisible, executeRecaptcha, onError]);
140
+ useEffect(() => {
141
+ if (!scriptLoaded) return;
62
142
  refreshIntervalRef.current = setInterval(() => {
63
143
  executeRecaptcha();
64
144
  }, refreshInterval);
@@ -67,7 +147,7 @@ function RecaptchaWrapper({
67
147
  clearInterval(refreshIntervalRef.current);
68
148
  }
69
149
  };
70
- }, [executeRecaptcha, refreshInterval]);
150
+ }, [scriptLoaded, executeRecaptcha, refreshInterval]);
71
151
  if (!siteKey) {
72
152
  if (process.env.NODE_ENV === "development") {
73
153
  console.warn(
@@ -76,7 +156,7 @@ function RecaptchaWrapper({
76
156
  }
77
157
  return null;
78
158
  }
79
- return /* @__PURE__ */ jsxs(Fragment, { children: [
159
+ return /* @__PURE__ */ jsxs("div", { ref: containerRef, style: { display: "contents" }, children: [
80
160
  /* @__PURE__ */ jsx(
81
161
  "input",
82
162
  {
@@ -87,15 +167,28 @@ function RecaptchaWrapper({
87
167
  "data-testid": "recaptcha-token-input"
88
168
  }
89
169
  ),
90
- /* @__PURE__ */ jsx(
170
+ !lazy && /* @__PURE__ */ jsx(
91
171
  Script,
92
172
  {
93
173
  src: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
94
174
  strategy: "afterInteractive",
95
175
  onLoad: () => {
176
+ if (typeof window !== "undefined") {
177
+ window.__recaptchaLoaded = true;
178
+ window.__recaptchaLoading = false;
179
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
180
+ window.__recaptchaCallbacks = [];
181
+ }
182
+ setScriptLoaded(true);
96
183
  executeRecaptcha();
97
184
  },
98
185
  onError: () => {
186
+ if (typeof window !== "undefined") {
187
+ window.__recaptchaLoading = false;
188
+ const error = new Error("Failed to load reCAPTCHA script");
189
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
190
+ window.__recaptchaCallbacks = [];
191
+ }
99
192
  console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
100
193
  if (onError) {
101
194
  onError(new Error("Failed to load reCAPTCHA script"));
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/constants/index.ts","../src/client/index.tsx","../src/server/index.ts"],"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","/**\n * reCAPTCHA v3 Server-Side Validation\n *\n * Functions for validating reCAPTCHA tokens in Next.js Server Actions.\n *\n * @see https://developers.google.com/recaptcha/docs/verify\n * @packageDocumentation\n */\n\nimport type {\n RecaptchaValidationResult,\n RecaptchaVerifyResponse,\n RecaptchaValidationOptions,\n} from \"../types\";\nimport { RECAPTCHA_CONFIG, DEFAULT_SCORE_THRESHOLD } from \"../constants\";\n\n/**\n * Validate a reCAPTCHA token with Google's API\n *\n * @param token - The reCAPTCHA token from the client\n * @param expectedAction - The expected action name (optional, for extra security)\n * @param options - Additional validation options\n * @returns Validation result with success status and score\n *\n * @example Basic validation\n * ```ts\n * const result = await validateRecaptcha(token, \"contact_form\");\n * if (!result.success) {\n * return { success: false, message: result.error };\n * }\n * ```\n *\n * @example Custom threshold for sensitive forms\n * ```ts\n * const result = await validateRecaptcha(token, \"payment_form\", {\n * scoreThreshold: 0.7, // Higher threshold for payments\n * secretKey: process.env.RECAPTCHA_SECRET_KEY,\n * });\n * ```\n *\n * @example Skip validation in development\n * ```ts\n * const result = await validateRecaptcha(token, \"test_form\", {\n * debug: true, // Enable debug logging\n * });\n * // Returns { success: true, score: 1, skipped: true } if not configured\n * ```\n */\nexport async function validateRecaptcha(\n token: string | null | undefined,\n expectedAction?: string,\n options: RecaptchaValidationOptions = {}\n): Promise<RecaptchaValidationResult> {\n const {\n scoreThreshold = DEFAULT_SCORE_THRESHOLD,\n secretKey = process.env.RECAPTCHA_SECRET_KEY,\n debug = process.env.NODE_ENV === \"development\",\n } = options;\n\n // Check if reCAPTCHA is configured\n if (!secretKey) {\n if (debug) {\n console.warn(\n \"[reCAPTCHA] Secret key not configured. Skipping validation.\"\n );\n }\n return {\n success: true,\n score: 1,\n skipped: true,\n };\n }\n\n // Check if token is provided\n if (!token) {\n return {\n success: false,\n score: 0,\n error: \"reCAPTCHA token is missing\",\n };\n }\n\n try {\n // Verify token with Google\n const response = await fetch(RECAPTCHA_CONFIG.verifyUrl, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n },\n body: new URLSearchParams({\n secret: secretKey,\n response: token,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n\n const data: RecaptchaVerifyResponse = await response.json();\n\n if (debug) {\n console.log(\"[reCAPTCHA] Verification response:\", {\n success: data.success,\n score: data.score,\n action: data.action,\n hostname: data.hostname,\n });\n }\n\n // Check if verification failed\n if (!data.success) {\n return {\n success: false,\n score: 0,\n error: `reCAPTCHA verification failed: ${data[\"error-codes\"]?.join(\", \") || \"Unknown error\"}`,\n rawResponse: data,\n };\n }\n\n // Check score threshold\n if (data.score < scoreThreshold) {\n return {\n success: false,\n score: data.score,\n error: `reCAPTCHA score too low: ${data.score} (threshold: ${scoreThreshold})`,\n rawResponse: data,\n };\n }\n\n // Check action if provided\n if (expectedAction && data.action !== expectedAction) {\n return {\n success: false,\n score: data.score,\n error: `reCAPTCHA action mismatch: expected \"${expectedAction}\", got \"${data.action}\"`,\n rawResponse: data,\n };\n }\n\n // Validation passed\n return {\n success: true,\n score: data.score,\n rawResponse: data,\n };\n } catch (error) {\n console.error(\"[reCAPTCHA] Validation error:\", error);\n return {\n success: false,\n score: 0,\n error:\n error instanceof Error\n ? `reCAPTCHA validation error: ${error.message}`\n : \"reCAPTCHA validation error\",\n };\n }\n}\n\n/**\n * Check if reCAPTCHA is enabled (secret key is configured)\n *\n * @param secretKey - Optional explicit secret key to check\n * @returns true if reCAPTCHA is configured\n *\n * @example\n * ```ts\n * if (isRecaptchaEnabled()) {\n * // Require reCAPTCHA validation\n * } else {\n * // Skip validation in development\n * }\n * ```\n */\nexport function isRecaptchaEnabled(secretKey?: string): boolean {\n return !!(secretKey ?? process.env.RECAPTCHA_SECRET_KEY);\n}\n\n/**\n * Extract reCAPTCHA token from FormData\n *\n * @param formData - Form data containing the token\n * @param fieldName - Name of the token field (default: \"recaptchaToken\")\n * @returns The token string or null\n *\n * @example\n * ```ts\n * export async function submitForm(prevState: State, formData: FormData) {\n * const token = getRecaptchaToken(formData);\n * const result = await validateRecaptcha(token, \"contact_form\");\n * // ...\n * }\n * ```\n */\nexport function getRecaptchaToken(\n formData: FormData,\n fieldName: string = \"recaptchaToken\"\n): string | null {\n const token = formData.get(fieldName);\n return typeof token === \"string\" ? token : null;\n}\n"]}
1
+ {"version":3,"sources":["../src/constants/index.ts","../src/client/index.tsx","../src/server/index.ts"],"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, 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 * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={(token) => console.log(\"Token:\", token)}\n * onError={(error) => console.error(\"Error:\", error)}\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 [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 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 // 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 // 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","/**\n * reCAPTCHA v3 Server-Side Validation\n *\n * Functions for validating reCAPTCHA tokens in Next.js Server Actions.\n *\n * @see https://developers.google.com/recaptcha/docs/verify\n * @packageDocumentation\n */\n\nimport type {\n RecaptchaValidationResult,\n RecaptchaVerifyResponse,\n RecaptchaValidationOptions,\n} from \"../types\";\nimport { RECAPTCHA_CONFIG, DEFAULT_SCORE_THRESHOLD } from \"../constants\";\n\n/**\n * Validate a reCAPTCHA token with Google's API\n *\n * @param token - The reCAPTCHA token from the client\n * @param expectedAction - The expected action name (optional, for extra security)\n * @param options - Additional validation options\n * @returns Validation result with success status and score\n *\n * @example Basic validation\n * ```ts\n * const result = await validateRecaptcha(token, \"contact_form\");\n * if (!result.success) {\n * return { success: false, message: result.error };\n * }\n * ```\n *\n * @example Custom threshold for sensitive forms\n * ```ts\n * const result = await validateRecaptcha(token, \"payment_form\", {\n * scoreThreshold: 0.7, // Higher threshold for payments\n * secretKey: process.env.RECAPTCHA_SECRET_KEY,\n * });\n * ```\n *\n * @example Skip validation in development\n * ```ts\n * const result = await validateRecaptcha(token, \"test_form\", {\n * debug: true, // Enable debug logging\n * });\n * // Returns { success: true, score: 1, skipped: true } if not configured\n * ```\n */\nexport async function validateRecaptcha(\n token: string | null | undefined,\n expectedAction?: string,\n options: RecaptchaValidationOptions = {}\n): Promise<RecaptchaValidationResult> {\n const {\n scoreThreshold = DEFAULT_SCORE_THRESHOLD,\n secretKey = process.env.RECAPTCHA_SECRET_KEY,\n debug = process.env.NODE_ENV === \"development\",\n } = options;\n\n // Check if reCAPTCHA is configured\n if (!secretKey) {\n if (debug) {\n console.warn(\n \"[reCAPTCHA] Secret key not configured. Skipping validation.\"\n );\n }\n return {\n success: true,\n score: 1,\n skipped: true,\n };\n }\n\n // Check if token is provided\n if (!token) {\n return {\n success: false,\n score: 0,\n error: \"reCAPTCHA token is missing\",\n };\n }\n\n try {\n // Verify token with Google\n const response = await fetch(RECAPTCHA_CONFIG.verifyUrl, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n },\n body: new URLSearchParams({\n secret: secretKey,\n response: token,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n\n const data: RecaptchaVerifyResponse = await response.json();\n\n if (debug) {\n console.log(\"[reCAPTCHA] Verification response:\", {\n success: data.success,\n score: data.score,\n action: data.action,\n hostname: data.hostname,\n });\n }\n\n // Check if verification failed\n if (!data.success) {\n return {\n success: false,\n score: 0,\n error: `reCAPTCHA verification failed: ${data[\"error-codes\"]?.join(\", \") || \"Unknown error\"}`,\n rawResponse: data,\n };\n }\n\n // Check score threshold\n if (data.score < scoreThreshold) {\n return {\n success: false,\n score: data.score,\n error: `reCAPTCHA score too low: ${data.score} (threshold: ${scoreThreshold})`,\n rawResponse: data,\n };\n }\n\n // Check action if provided\n if (expectedAction && data.action !== expectedAction) {\n return {\n success: false,\n score: data.score,\n error: `reCAPTCHA action mismatch: expected \"${expectedAction}\", got \"${data.action}\"`,\n rawResponse: data,\n };\n }\n\n // Validation passed\n return {\n success: true,\n score: data.score,\n rawResponse: data,\n };\n } catch (error) {\n console.error(\"[reCAPTCHA] Validation error:\", error);\n return {\n success: false,\n score: 0,\n error:\n error instanceof Error\n ? `reCAPTCHA validation error: ${error.message}`\n : \"reCAPTCHA validation error\",\n };\n }\n}\n\n/**\n * Check if reCAPTCHA is enabled (secret key is configured)\n *\n * @param secretKey - Optional explicit secret key to check\n * @returns true if reCAPTCHA is configured\n *\n * @example\n * ```ts\n * if (isRecaptchaEnabled()) {\n * // Require reCAPTCHA validation\n * } else {\n * // Skip validation in development\n * }\n * ```\n */\nexport function isRecaptchaEnabled(secretKey?: string): boolean {\n return !!(secretKey ?? process.env.RECAPTCHA_SECRET_KEY);\n}\n\n/**\n * Extract reCAPTCHA token from FormData\n *\n * @param formData - Form data containing the token\n * @param fieldName - Name of the token field (default: \"recaptchaToken\")\n * @returns The token string or null\n *\n * @example\n * ```ts\n * export async function submitForm(prevState: State, formData: FormData) {\n * const token = getRecaptchaToken(formData);\n * const result = await validateRecaptcha(token, \"contact_form\");\n * // ...\n * }\n * ```\n */\nexport function getRecaptchaToken(\n formData: FormData,\n fieldName: string = \"recaptchaToken\"\n): string | null {\n const token = formData.get(fieldName);\n return typeof token === \"string\" ? token : null;\n}\n"]}
@@ -57,6 +57,10 @@ interface RecaptchaWrapperProps {
57
57
  onTokenGenerated?: (token: string) => void;
58
58
  /** Callback when an error occurs */
59
59
  onError?: (error: Error) => void;
60
+ /** Enable lazy loading (default: false for backward compatibility) */
61
+ lazy?: boolean;
62
+ /** IntersectionObserver rootMargin for lazy loading (default: "200px") */
63
+ lazyRootMargin?: string;
60
64
  }
61
65
  /**
62
66
  * Configuration options for reCAPTCHA validation
@@ -91,6 +95,15 @@ declare global {
91
95
  action: string;
92
96
  }) => Promise<string>;
93
97
  };
98
+ /** Flag to track if reCAPTCHA script has loaded */
99
+ __recaptchaLoaded?: boolean;
100
+ /** Flag to track if reCAPTCHA script is currently loading */
101
+ __recaptchaLoading?: boolean;
102
+ /** Callbacks to execute when script finishes loading */
103
+ __recaptchaCallbacks?: Array<{
104
+ onLoad: () => void;
105
+ onError: (error: Error) => void;
106
+ }>;
94
107
  }
95
108
  }
96
109
 
@@ -57,6 +57,10 @@ interface RecaptchaWrapperProps {
57
57
  onTokenGenerated?: (token: string) => void;
58
58
  /** Callback when an error occurs */
59
59
  onError?: (error: Error) => void;
60
+ /** Enable lazy loading (default: false for backward compatibility) */
61
+ lazy?: boolean;
62
+ /** IntersectionObserver rootMargin for lazy loading (default: "200px") */
63
+ lazyRootMargin?: string;
60
64
  }
61
65
  /**
62
66
  * Configuration options for reCAPTCHA validation
@@ -91,6 +95,15 @@ declare global {
91
95
  action: string;
92
96
  }) => Promise<string>;
93
97
  };
98
+ /** Flag to track if reCAPTCHA script has loaded */
99
+ __recaptchaLoaded?: boolean;
100
+ /** Flag to track if reCAPTCHA script is currently loading */
101
+ __recaptchaLoading?: boolean;
102
+ /** Callbacks to execute when script finishes loading */
103
+ __recaptchaCallbacks?: Array<{
104
+ onLoad: () => void;
105
+ onError: (error: Error) => void;
106
+ }>;
94
107
  }
95
108
  }
96
109
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@silverassist/recaptcha",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Google reCAPTCHA v3 integration for Next.js applications with Server Actions support",
5
5
  "author": "Miguel Colmenares <me@miguelcolmenares.com>",
6
6
  "license": "Polyform-Noncommercial-1.0.0",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/SilverAssist/recaptcha.git"
9
+ "url": "git+https://github.com/SilverAssist/recaptcha.git"
10
10
  },
11
11
  "bugs": {
12
12
  "url": "https://github.com/SilverAssist/recaptcha/issues"