PluginProbe
Extendify / trunk
Extendify vtrunk
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
← All changes | src/Shared/api/pluginsActivation.js +102 -41 3.1.4 → trunk View file →
@@ -1,51 +1,113 @@
1 1 import apiFetch from '@wordpress/api-fetch';
2 2 import { addQueryArgs } from '@wordpress/url';
3 3
4 -const getRecaptchaToken = (action, siteKey) =>
5 - new Promise((resolve, reject) => {
6 - if (!siteKey) {
7 - reject(new Error(`No reCAPTCHA site key for the ${action} action`));
4 +let recaptchaReady;
5 +const loadRecaptcha = () => {
6 + recaptchaReady ??= new Promise((resolve, reject) => {
7 + const ready = () => window.grecaptcha.enterprise.ready(resolve);
8 + if (window.grecaptcha?.enterprise) {
9 + ready();
8 10 return;
9 11 }
10 12
11 13 const existing = document.querySelector(
12 - `script[src*="recaptcha/enterprise"]`,
14 + 'script[src*="recaptcha/enterprise"]',
13 15 );
14 - const load = () =>
15 - window.grecaptcha.enterprise.ready(async () => {
16 - try {
17 - resolve(
18 - await window.grecaptcha.enterprise.execute(siteKey, { action }),
19 - );
20 - } catch (error) {
21 - reject(error);
22 - }
23 - });
24 -
25 16 if (existing) {
26 - load();
17 + existing.addEventListener('load', ready);
27 18 return;
28 19 }
29 20
30 21 const script = document.createElement('script');
31 - script.src = `https://www.google.com/recaptcha/enterprise.js?render=${siteKey}`;
22 + script.src =
23 + 'https://www.google.com/recaptcha/enterprise.js?render=explicit';
32 24 script.async = true;
33 - script.onload = load;
25 + script.onload = ready;
26 + script.onerror = () => {
27 + // A cached rejection would block every retry.
28 + recaptchaReady = undefined;
29 + reject(new Error('Failed to load the reCAPTCHA script'));
30 + };
34 31 document.head.appendChild(script);
35 32 });
33 + return recaptchaReady;
34 +};
36 35
37 -const createAccount = async ({
38 - slug,
36 +// enterprise.js can't load twice, and execute() needs a rendered site key —
37 +// one widget per key.
38 +const recaptchaWidgets = new Map();
39 +const renderedKeys = new Set();
40 +
41 +const renderWidget = async (siteKey) => {
42 + await loadRecaptcha();
43 +
44 + const container = document.createElement('div');
45 + document.body.appendChild(container);
46 + const widget = window.grecaptcha.enterprise.render(container, {
47 + sitekey: siteKey,
48 + size: 'invisible',
49 + });
50 + renderedKeys.add(siteKey);
51 +
52 + return widget;
53 +};
54 +
55 +// Keeps the script load and the widget render off the click's deadline.
56 +export const prewarmRecaptcha = (siteKey) => {
57 + if (!recaptchaWidgets.has(siteKey)) {
58 + const widget = renderWidget(siteKey);
59 + widget.catch(() => recaptchaWidgets.delete(siteKey));
60 + recaptchaWidgets.set(siteKey, widget);
61 + }
62 + return recaptchaWidgets.get(siteKey);
63 +};
64 +
65 +const getRecaptchaToken = async (action, siteKey, timings = {}) => {
66 + if (!siteKey) {
67 + throw new Error(`No reCAPTCHA site key for the ${action} action`);
68 + }
69 +
70 + timings.captchaWasWarm = renderedKeys.has(siteKey);
71 + const start = Date.now();
72 +
73 + try {
74 + const widget = await prewarmRecaptcha(siteKey);
75 +
76 + // Without await, finally runs before execute settles and records ~0ms.
77 + return await window.grecaptcha.enterprise.execute(widget, { action });
78 + } finally {
79 + timings.captchaTimeInMs = Date.now() - start;
80 + }
81 +};
82 +
83 +// api-fetch throws the parsed body and drops the Response, so parse:false is the only way to keep the status.
84 +const post = async (options) => {
85 + try {
86 + const response = await apiFetch({
87 + ...options,
88 + method: 'POST',
89 + parse: false,
90 + });
91 + return await response.json().catch(() => undefined);
92 + } catch (error) {
93 + if (typeof error?.json !== 'function') throw error;
94 +
95 + const body = await error.json().catch(() => ({ code: 'invalid_json' }));
96 + throw { ...body, httpStatus: error.status };
97 + }
98 +};
99 +
100 +const createAccount = ({
101 + endpoint,
39 102 email,
40 103 marketingConsent,
41 104 termsAgreed,
42 105 signal,
43 106 scriptData,
44 -}) => {
45 - await apiFetch({
46 - path: `extendify/v1/${slug}/create-account`,
47 - method: 'POST',
107 +}) =>
108 + post({
109 + path: endpoint,
48 110 data: {
49 111 email,
50 112 marketingConsent,
51 113 termsAgreed,
@@ -52,39 +114,40 @@
52 114 ...scriptData,
53 115 },
54 116 signal,
55 117 });
56 -};
57 118
58 119 /*
59 120 * Plugin entries shape:
60 - * createAccountCallback: (data) => Promise<void> — performs the account creation request
61 - * idempotent: boolean (default true) — false skips retries; use when re-sending the same request could cause errors
121 + * createAccountCallback: (data) => Promise<body> — performs the account creation request
122 + * data.endpoint: the route PHP registered — requesting and recording must not drift
123 + * data.timings: out-param — write the captcha timings here; they survive a throw
62 124 */
63 125 export const pluginsActivation = {
64 126 simplybook: {
65 - idempotent: false,
66 127 createAccountCallback: async ({
67 128 scriptData,
129 + endpoint,
68 130 email,
69 131 marketingConsent,
70 132 termsAgreed,
71 133 signal,
134 + timings,
72 135 }) => {
73 136 const captchaToken = await getRecaptchaToken(
74 137 scriptData?.recaptchaAction,
75 138 scriptData?.recaptchaSiteKey,
139 + timings,
76 140 );
77 141
78 142 // Hit the endpoint via ?rest_route= so the request URL contains "simplybook" —
79 143 // SimplyBook only registers its onboarding routes when it does, else they 404.
80 144 const url = addQueryArgs(`${window.extSharedData.homeUrl}/`, {
81 - rest_route: '/extendify/v1/simplybook/create-account',
145 + rest_route: `/${endpoint}`,
82 146 });
83 147
84 - await apiFetch({
148 + return post({
85 149 url,
86 - method: 'POST',
87 150 data: {
88 151 email,
89 152 marketingConsent,
90 153 termsAgreed,
@@ -94,33 +157,31 @@
94 157 });
95 158 },
96 159 },
97 160 'translatepress-multilingual': {
98 - createAccountCallback: (data) =>
99 - createAccount({ slug: 'translatepress-multilingual', ...data }),
161 + createAccountCallback: createAccount,
100 162 },
101 163 imagify: {
102 - createAccountCallback: (data) =>
103 - createAccount({ slug: 'imagify', ...data }),
164 + createAccountCallback: createAccount,
104 165 },
105 166 metricool: {
106 - idempotent: false,
107 167 createAccountCallback: async ({
108 168 scriptData,
169 + endpoint,
109 170 email,
110 171 marketingConsent,
111 172 termsAgreed,
112 173 signal,
174 + timings,
113 175 }) => {
114 176 const captchaToken = await getRecaptchaToken(
115 177 scriptData?.recaptchaAction,
116 178 scriptData?.recaptchaSiteKey,
179 + timings,
117 180 );
118 181
119 - // The "/v1" segment is what makes Metricool register its logout route.
120 - await apiFetch({
121 - path: 'extendify/v1/metricool/v1/create-account',
122 - method: 'POST',
182 + return post({
183 + path: endpoint,
123 184 data: {
124 185 email,
125 186 marketingConsent,
126 187 termsAgreed,