Setting the file. One moment.
SEO Redirects · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
137
function latestByKey
— line 137
This file
Number 41.159
Position 159 of 174
Type JavaScript
Size 19 KB
Lines 365 lib/ seo-redirects.js
JavaScript · 365 lines · 19 KB
14 // rule; Bulk Create fails that item instead. `forceReplace` is never sent.
15 // - Writes need an APP or WIX_USER identity with Manage SEO Settings on the site.
16 // MEMBER/unknown identities are rejected before recording any write intent.
17 // - Every write is preceded by a durable `write_intent` row and followed by List Redirects
18 // reconciliation: HTTP 200, a missing result or a success without an id is not proof.
19 // - A changed inventory between batches means someone else is editing redirects: stop.
20
21 const planLib = require ( '../../../lib/url-redirect-plan.js' );
22 const state = require ( '../../../lib/url-preservation-state.js' );
23
24 const WIXAPIS = 'https://www.wixapis.com' ;
25 const BASE = `${ WIXAPIS }/seo-redirects-service/v1` ;
26 const BULK_CREATE_MAX = 100 ;
27
28 function buildListRedirectsRequest () {
29 return { method: 'GET' , url: `${ BASE }/redirects` , operation: 'seo.redirects.list' , verification: 'unverified' };
30 }
31
32 function assertSafeExactRule ( rule , index ) {
33 const label = `redirects[${ index }]` ;
34 if ( ! rule || typeof rule.from !== 'string' || typeof rule.to !== 'string' ) throw new Error ( `${ label } needs from/to strings` );
35 const from = planLib. parseAddress (rule.from);
36 const to = planLib. parseAddress (rule.to);
37 if ( ! from.ok || from.external || ! to.ok || to.external) throw new Error ( `${ label } must be a same-site relative rule` );
38 if (from.conflictPath === '/' ) throw new Error ( `${ label }.from cannot be the site root` );
39 if (from.query || from.fragment) throw new Error ( `${ label }.from cannot carry a query or fragment` );
40 if (rule.options || rule.language || rule.forceReplace) throw new Error ( `${ label } may not pass group, language or replace options` );
41 }
42
43 function buildBulkCreateRedirectsRequest ( rules ) {
44 if ( ! Array. isArray (rules) || rules. length === 0 ) throw new Error ( 'bulk create needs at least one redirect' );
45 if (rules. length > BULK_CREATE_MAX ) throw new Error ( `bulk create is capped at ${ BULK_CREATE_MAX } redirects` );
46 rules. forEach (assertSafeExactRule);
47 return {
48 method: 'POST' ,
49 url: `${ BASE }/bulk/redirects/create` ,
50 operation: 'seo.redirects.bulk-create' ,
51 verification: 'unverified' ,
52 body: { redirects: rules. map (( rule ) => ({ from: rule.from, to: rule.to })), returnFullEntity: true },
53 };
54 }
55
56 function isExactMatch ( existing , rule ) {
57 const ex = planLib. normalizeInventoryRule (existing);
58 const to = planLib. parseAddress (rule.to);
59 return ex.fromConflict === rule.fromConflict && ! ex.scope.groupRedirect && ex.scope.language === '*'
60 && ! ex.toExternal && ex.toConflict === to.conflictPath && ! ex.toQuery;
61 }
62
63 // Per-item outcomes by originalIndex. Missing entries and undetailed failures are unknown.
64 function correlateBulkCreate ( response , inputs ) {
65 const outcomes = inputs. map (() => ({ reported: null , errorCode: null , id: null }));
66 const results = Array. isArray (response && response.results) ? response.results : [];
67 const seen = new Set ();
68 for ( const result of results) {
69 const meta = (result && result.itemMetadata) || {};
70 const index = meta.originalIndex;
71 if ( ! Number. isInteger (index) || index < 0 || index >= inputs. length || seen. has (index)) continue ;
72 seen. add (index);
73 outcomes[index] = {
74 reported: meta.success === true ? 'success' : meta.success === false ? 'failure' : null ,
75 errorCode: (meta.error && meta.error.code) || null ,
76 id: meta.id || (result.item && result.item.id) || null ,
77 };
78 }
79 const undetailed = Number ((response && response.bulkActionMetadata && response.bulkActionMetadata.undetailedFailures) || 0 );
80 return { outcomes, undetailedFailures: undetailed, needsReconciliation: true };
81 }
82
83 function classifyHttpError ( error ) {
84 // Refused by the client before sending: no status, and nothing reached Wix. Never transient
85 // (a retry is refused the same way) and never a possibly-applied write.
86 if (error && error.requestSent === false ) return 'not_sent' ;
87 const status = error && Number. isInteger (error.status) ? error.status : null ;
88 if (status === 401 || status === 403 ) return 'permission' ;
89 if (status === 400 || status === 404 || status === 422 ) return 'schema' ;
90 if (status === 429 || (status && status >= 500 ) || status === null ) return 'transient' ;
91 return 'schema' ;
92 }
93
94 async function listWithBackoff ( client , { retries = 3 , sleep , baseDelayMs = 500 } = {}) {
95 let attempt = 0 ;
96 for (;;) {
97 try {
98 const response = await client. send ( buildListRedirectsRequest ());
99 if ( ! response || ! Array. isArray (response.redirects)) {
100 throw Object. assign ( new Error ( 'List Redirects returned no redirects[] array' ), { status: 422 });
101 }
102 return response.redirects;
103 } catch (error) {
104 if (wixAuth. isRunInterruption (error)) throw error;
105 const kind = classifyHttpError (error);
106 if (kind !== 'transient' || attempt >= retries) throw Object. assign (error, { failureKind: kind });
107 attempt += 1 ;
108 await sleep (baseDelayMs * 2 ** (attempt - 1 ));
109 }
110 }
111 }
112
113 function credentialProblem ( credential , siteId ) {
114 if ( ! credential || ! credential.token) return 'missing' ;
115 if ( ! [ 'APP' , 'WIX_USER' ]. includes (credential.identity)) return 'unsupported_identity' ;
116 if (credential.siteId !== siteId) return 'site_mismatch' ;
117 return null ;
118 }
119
120 function action ( code , severity , text , deadlineReason , affectedCount , entity = 'url-redirects' ) {
121 return { code, severity, action: text, deadlineReason, entity, affectedCount };
122 }
123
124 function ruleIdentity ( rule ) {
125 return JSON . stringify ([rule.id || null , rule.from, rule.to, planLib. apiScopeOf (rule)]);
126 }
127
128 function inventoryDrift ( before , after , batch ) {
129 const beforeIds = new Set (before. map (ruleIdentity));
130 const afterIds = new Set (after. map (ruleIdentity));
131 const added = after. filter (( rule ) => ! beforeIds. has ( ruleIdentity (rule)) && ! batch. some (( mine ) => isExactMatch (rule, mine)));
132 const removed = before. filter (( rule ) => ! afterIds. has ( ruleIdentity (rule)));
133 return { added, removed };
134 }
135
136 // Fold execution rows into the latest per remote key.
137 function latestByKey ( executionRows ) {
138 const byKey = new Map ();
139 for ( const row of executionRows) byKey. set (row.ruleKey, row);
140 return byKey;
141 }
142
143 // Apply the current plan. `readClient` may use user/member authorization; `writeClient`
144 // must be built from the site's declared APP or WIX_USER credential. Returns counts and operator actions only.
145 async function applyRedirectPlan ( options ) {
146 const {
147 projectDir ,
148 plan ,
149 readClient ,
150 initialInventory = null ,
151 initialInventoryError = null ,
152 writeClient = null ,
153 credential = null ,
154 launchPlan = null ,
155 exclusiveEditWindowConfirmed = false ,
156 runId ,
157 sleep = ( ms ) => new Promise (( resolve ) => setTimeout (resolve, ms)),
158 now = () => new Date (). toISOString (),
159 batchSize = BULK_CREATE_MAX ,
160 } = options;
161 const summary = { installed: 0 , alreadyPresent: 0 , failed: 0 , outcomeUnknown: 0 , manualReview: 0 , writesAttempted: 0 , batches: 0 , stopped: null };
162 const actions = [];
163 const report = () => ({ summary, operatorActions: actions });
164 const size = Math. min (batchSize, BULK_CREATE_MAX );
165
166 // Gates that must hold before any write intent exists.
167 const credentialIssue = credentialProblem (credential, plan.siteId);
168 if (credentialIssue) {
169 summary.stopped = 'redirect_write_credential_missing' ;
170 actions. push ( action ( 'redirect_write_credential_missing' , 'high' ,
171 `Configure a Wix app or Wix user credential with Manage SEO Settings for this destination site (site-member credentials are not supported) (problem: ${ credentialIssue }); redirect planning and read-only inventory still work. See execution/url-preservation-report.md.` ,
172 'old links are not protected until redirects can be written' , plan.remoteRules. filter (( r ) => r.disposition === 'write_candidate' ). length ));
173 return report ();
174 }
175 if ( ! launchPlan || launchPlan.siteId !== plan.siteId || launchPlan.deploymentId !== plan.deploymentId) {
176 summary.stopped = 'launch_plan_mismatch' ;
177 actions. push ( action ( 'redirect_launch_plan_mismatch' , 'high' , 'Record a launch plan for this destination site and deployment before applying redirects. See execution/url-preservation-report.md.' , 'redirects must target the deployment that will serve the production domain' , 1 ));
178 return report ();
179 }
180 if (launchPlan.productionHostname !== plan.productionHostname) {
181 summary.stopped = 'production_hostname_mismatch' ;
182 actions. push ( action ( 'redirect_production_hostname_mismatch' , 'critical' , 'The launch plan names a different production hostname than the declared source domains. Changing the production hostname is a separate decision; fix the launch plan.' , 'links on the unchanged production domain would not be protected' , 1 ));
183 return report ();
184 }
185 if (launchPlan.frontend.redirectSupport !== 'honors_wix_redirects' ) {
186 summary.stopped = 'redirect_frontend_unsupported' ;
187 const affected = plan.remoteRules. filter (( r ) => r.disposition === 'write_candidate' ). length ;
188 actions. push ( action ( 'redirect_frontend_unsupported' , 'high' ,
189 `The selected frontend (${ launchPlan . frontend . model }) is not shown to serve Wix redirect rules (${ launchPlan . frontend . redirectSupport }). Give its hosting owner the redirect list in execution/url-preservation-report.md to configure there; no Wix rules were written.` ,
190 'Wix rules that the frontend ignores would not protect visitors' , affected));
191 return report ();
192 }
193 if ( ! exclusiveEditWindowConfirmed) {
194 summary.stopped = 'redirect_edit_window_required' ;
195 actions. push ( action ( 'redirect_edit_window_required' , 'medium' , 'Confirm that nobody else edits redirects on the destination site during apply (exclusive edit window), or hand the rule list over for manual entry. See execution/url-preservation-report.md.' , 'the redirect service has no compare-and-swap protection against concurrent edits' , 1 ));
196 return report ();
197 }
198
199 const executionFile = await state. loadRedirectExecution (projectDir);
200 let latest = latestByKey (executionFile.rows);
201
202 let inventory;
203 try {
204 if (initialInventoryError) throw initialInventoryError;
205 inventory = initialInventory || await listWithBackoff (readClient, { sleep });
206 } catch (error) {
207 if (wixAuth. isRunInterruption (error)) throw error;
208 summary.stopped = `inventory_${ error . failureKind || 'failed'}` ;
209 actions. push ( action ( 'redirect_inventory_unreadable' , 'high' , 'Existing redirects on the destination site could not be listed; nothing was written. Check the read credential and retry apply.' , 'writing without an inventory could replace or delete existing rules' , 1 ));
210 return report ();
211 }
212
213 const append = async ( rule , disposition , extra = {}) => {
214 const row = {
215 schemaVersion: state. SCHEMA_VERSION ,
216 runId,
217 planDigest: plan.planDigest,
218 deploymentId: plan.deploymentId,
219 siteId: plan.siteId,
220 ruleKey: rule.ruleKey,
221 from: rule.from,
222 to: rule.to || '' ,
223 origins: rule.origins || [],
224 obligationKeys: rule.obligationKeys || [],
225 disposition,
226 reasonCodes: extra.reasonCodes || [],
227 recordedAt: now (),
228 ... (extra.inventoryDigest ? { inventoryDigest: extra.inventoryDigest } : {}),
229 ... (extra.remoteId ? { remoteId: extra.remoteId } : {}),
230 ... (extra.error ? { error: extra.error } : {}),
231 };
232 await state. appendRedirectExecutionRow (projectDir, row);
233 latest. set (rule.ruleKey, row);
234 return row;
235 };
236
237 // Recovery: an intent or unknown outcome from an earlier run is resolved by exact semantics
238 // against the live inventory, never by a blind retry.
239 for ( const row of latest. values ()) {
240 if (row.disposition !== 'write_intent' && row.disposition !== 'outcome_unknown' ) continue ;
241 const rule = { ruleKey: row.ruleKey, from: row.from, to: row.to, origins: row.origins, obligationKeys: row.obligationKeys, fromConflict: planLib. parseAddress (row.from).conflictPath };
242 const match = inventory. find (( existing ) => isExactMatch (existing, rule));
243 if (match) await append (rule, 'installed' , { remoteId: match.id || undefined , reasonCodes: [ 'reconciled_after_interruption' ] });
244 else await append (rule, 'failed' , { reasonCodes: [ 'confirmed_absent_after_interruption' ] });
245 }
246
247 const preflight = planLib. preflightAgainstInventory (plan.remoteRules, inventory, plan.siteId);
248 const candidates = [];
249 for ( const rule of preflight.rules) {
250 if (rule.disposition === 'already_present' ) {
251 const prior = latest. get (rule.ruleKey);
252 if ( ! prior || ! [ 'installed' , 'already_present' ]. includes (prior.disposition)) {
253 await append (rule, 'already_present' , { remoteId: rule.remoteId || undefined });
254 }
255 summary.alreadyPresent += 1 ;
256 } else if (rule.disposition === 'manual_review' ) {
257 const prior = latest. get (rule.ruleKey);
258 if ( ! prior || prior.disposition !== 'manual_review' || JSON . stringify (prior.reasonCodes) !== JSON . stringify (rule.reasonCodes)) {
259 await append (rule, 'manual_review' , { reasonCodes: rule.reasonCodes });
260 }
261 summary.manualReview += 1 ;
262 } else if (rule.disposition === 'write_candidate' ) {
263 candidates. push (rule);
264 }
265 }
266
267 let expectedDigest = preflight.inventoryDigest;
268 for ( let offset = 0 ; offset < candidates. length ; offset += size) {
269 const batch = candidates. slice (offset, offset + size);
270 if (offset > 0 ) {
271 try {
272 inventory = await listWithBackoff (readClient, { sleep });
273 } catch (error) {
274 if (wixAuth. isRunInterruption (error)) throw error;
275 summary.stopped = `inventory_${ error . failureKind || 'failed'}` ;
276 break ;
277 }
278 if (planLib. inventoryDigest (inventory) !== expectedDigest) {
279 summary.stopped = 'concurrent_redirect_change' ;
280 actions. push ( action ( 'redirect_concurrent_change' , 'high' , 'Redirects on the destination site changed while apply was running; it stopped before the next batch. Re-run plan and apply inside an exclusive edit window.' , 'another editor could have created conflicting rules' , candidates. length - offset));
281 break ;
282 }
283 }
284 for ( const rule of batch) await append (rule, 'write_intent' , { inventoryDigest: expectedDigest });
285 summary.batches += 1 ;
286 summary.writesAttempted += batch. length ;
287 let response = null ;
288 let sendError = null ;
289 try {
290 response = await writeClient. send ( buildBulkCreateRedirectsRequest (batch. map (( rule ) => ({ from: rule.from, to: rule.to }))));
291 } catch (error) {
292 if (wixAuth. isRunInterruption (error)) throw error;
293 sendError = error;
294 }
295 if (sendError && classifyHttpError (sendError) !== 'transient' ) {
296 const kind = classifyHttpError (sendError);
297 for ( const rule of batch) await append (rule, 'failed' , { reasonCodes: [ `write_${ kind }_error` ], error: { status: sendError.status || null , code: kind } });
298 summary.failed += batch. length ;
299 summary.stopped = kind === 'permission' ? 'redirect_permission_denied' : kind === 'not_sent' ? 'redirect_write_not_sent' : 'redirect_request_rejected' ;
300 actions. push ( action (summary.stopped, 'high' , kind === 'permission'
301 ? 'The destination site rejected the redirect write credential. Check that the app is installed on this site with Manage SEO Settings, then re-run apply.'
302 : kind === 'not_sent'
303 ? `The migration client refused the redirect write before sending it (${ sendError . code || 'refused'}); nothing was written. Re-run apply with a client authorized for redirect writes.`
304 : 'The redirect service rejected the request shape. Re-check the current API schema before retrying.' , 'no further redirects were written' , candidates. length - offset));
305 break ;
306 }
307 const correlated = response ? correlateBulkCreate (response, batch) : null ;
308 let after;
309 try {
310 after = await listWithBackoff (readClient, { sleep });
311 } catch (error) {
312 if (wixAuth. isRunInterruption (error)) throw error;
313 for ( const rule of batch) await append (rule, 'outcome_unknown' , { reasonCodes: [ 'reconciliation_list_failed' ] });
314 summary.outcomeUnknown += batch. length ;
315 summary.stopped = 'reconciliation_unavailable' ;
316 actions. push ( action ( 'redirect_outcome_unknown' , 'high' , 'Redirect writes were sent but could not be confirmed. Re-run apply to reconcile them against the site before anything else changes redirects.' , 'an unconfirmed write may or may not protect the old link' , batch. length ));
317 break ;
318 }
319 batch. forEach (( rule , index ) => {
320 rule.outcome = correlated ? correlated.outcomes[index] : null ;
321 });
322 for ( const rule of batch) {
323 const match = after. find (( existing ) => isExactMatch (existing, rule));
324 if (match) {
325 await append (rule, 'installed' , { remoteId: match.id || undefined });
326 summary.installed += 1 ;
327 } else {
328 const code = (rule.outcome && rule.outcome.errorCode) || (sendError ? 'transient_send_error' : 'absent_after_write' );
329 await append (rule, 'failed' , { reasonCodes: [ 'confirmed_absent_after_write' ], error: { code } });
330 summary.failed += 1 ;
331 }
332 }
333 // Our own confirmed writes are the only expected change. Any other new rule, or an
334 // existing rule that disappeared (for example a loop-closing deletion), means the site
335 // changed underneath us: stop before the next batch.
336 const drift = inventoryDrift (inventory, after, batch);
337 if (drift.added. length || drift.removed. length ) {
338 summary.stopped = 'concurrent_redirect_change' ;
339 actions. push ( action ( 'redirect_concurrent_change' , drift.removed. length ? 'critical' : 'high' ,
340 `Redirects on the destination site changed during apply (${ drift . added . length } unexpected new, ${ drift . removed . length } missing existing rule(s)); it stopped. Review the site's redirects, then re-run plan and apply inside an exclusive edit window.` ,
341 'another editor or a service-side deletion could have changed existing rules' , drift.added. length + drift.removed. length ));
342 break ;
343 }
344 inventory = after;
345 expectedDigest = planLib. inventoryDigest (after);
346 if (sendError) {
347 summary.stopped = 'transient_write_error' ;
348 break ;
349 }
350 }
351 return report ();
352 }
353
354 module . exports = {
355 BASE,
356 BULK_CREATE_MAX,
357 buildListRedirectsRequest,
358 buildBulkCreateRedirectsRequest,
359 correlateBulkCreate,
360 classifyHttpError,
361 listWithBackoff,
362 isExactMatch,
363 credentialProblem,
364 applyRedirectPlan,
365 };