diff --git a/package-lock.json b/package-lock.json index e583faf3e..7b2be179f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "strip-ansi": "^7.1.2", "tar": "^7.5.2", "ts-node": "^10.9.2", + "tus-js-client": "^4.3.1", "unzipper": "^0.12.3", "winreg": "^1.2.5", "wpcom": "^7.1.1", @@ -20665,9 +20666,9 @@ } }, "node_modules/js-base64": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", - "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==", + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, "node_modules/js-tokens": { @@ -24352,25 +24353,14 @@ } }, "node_modules/proper-lockfile": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-2.0.1.tgz", - "integrity": "sha512-rjaeGbsmhNDcDInmwi4MuI6mRwJu6zq8GjYCLuSuE7GF+4UjgzkL69sVKKJ2T2xH61kK7rXvGYpvaTu909oXaQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", "license": "MIT", "dependencies": { - "graceful-fs": "^4.1.2", - "retry": "^0.10.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/proper-lockfile/node_modules/retry": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz", - "integrity": "sha512-ZXUSQYTHdl3uS7IuCehYfMzKyIDBNoAuUblvy5oGO5UJSUTmStUUVPXbA9Qxd173Bgre53yCQczQuHgRWAdvJQ==", - "license": "MIT", - "engines": { - "node": "*" + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" } }, "node_modules/property-information": { @@ -25301,7 +25291,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -27527,18 +27516,21 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/tus-js-client": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-2.3.2.tgz", - "integrity": "sha512-5a2rm7gp+G7Z+ZB0AO4PzD/dwczB3n1fZeWO5W8AWLJ12RRk1rY4Aeb2VAYX9oKGE+/rGPrdxoFPA/vDSVKnpg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz", + "integrity": "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==", "license": "MIT", "dependencies": { "buffer-from": "^1.1.2", "combine-errors": "^3.0.3", "is-stream": "^2.0.0", - "js-base64": "^2.6.1", + "js-base64": "^3.7.2", "lodash.throttle": "^4.1.1", - "proper-lockfile": "^2.0.1", + "proper-lockfile": "^4.1.2", "url-parse": "^1.5.7" + }, + "engines": { + "node": ">=18" } }, "node_modules/tus-js-client/node_modules/is-stream": { @@ -29323,6 +29315,70 @@ "wp-error": "^1.3.0" } }, + "node_modules/wpcom-xhr-request/node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/wpcom/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wpcom/node_modules/js-base64": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", + "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==", + "license": "BSD-3-Clause" + }, + "node_modules/wpcom/node_modules/proper-lockfile": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-2.0.1.tgz", + "integrity": "sha512-rjaeGbsmhNDcDInmwi4MuI6mRwJu6zq8GjYCLuSuE7GF+4UjgzkL69sVKKJ2T2xH61kK7rXvGYpvaTu909oXaQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "retry": "^0.10.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/wpcom/node_modules/retry": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz", + "integrity": "sha512-ZXUSQYTHdl3uS7IuCehYfMzKyIDBNoAuUblvy5oGO5UJSUTmStUUVPXbA9Qxd173Bgre53yCQczQuHgRWAdvJQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/wpcom/node_modules/tus-js-client": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-2.3.2.tgz", + "integrity": "sha512-5a2rm7gp+G7Z+ZB0AO4PzD/dwczB3n1fZeWO5W8AWLJ12RRk1rY4Aeb2VAYX9oKGE+/rGPrdxoFPA/vDSVKnpg==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.1.2", + "combine-errors": "^3.0.3", + "is-stream": "^2.0.0", + "js-base64": "^2.6.1", + "lodash.throttle": "^4.1.1", + "proper-lockfile": "^2.0.1", + "url-parse": "^1.5.7" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 0c3478445..ba4f220b8 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "strip-ansi": "^7.1.2", "tar": "^7.5.2", "ts-node": "^10.9.2", + "tus-js-client": "^4.3.1", "unzipper": "^0.12.3", "winreg": "^1.2.5", "wpcom": "^7.1.1", diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 1bb778c87..6552b0afe 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -11,6 +11,7 @@ import { usePullPushStates, } from 'src/hooks/sync-sites/use-pull-push-states'; import { useAuth } from 'src/hooks/use-auth'; +import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useSyncStatesProgressInfo, PushStateProgressInfo, @@ -308,6 +309,7 @@ export function useSyncPush( { try { const response = await getIpcApi().pushArchive( + selectedSite.id, remoteSiteId, archivePath, options?.optionsToSync, @@ -378,6 +380,24 @@ export function useSyncPush( { isKeyCancelled, ] ); + useIpcListener( + 'sync-upload-paused', + ( _event, payload: { selectedSiteId: string; remoteSiteId: number; error: string } ) => { + updatePushState( payload.selectedSiteId, payload.remoteSiteId, { + status: pushStatesProgressInfo.uploadingPaused, + } ); + } + ); + + useIpcListener( + 'sync-upload-resumed', + ( _event, payload: { selectedSiteId: string; remoteSiteId: number } ) => { + updatePushState( payload.selectedSiteId, payload.remoteSiteId, { + status: pushStatesProgressInfo.uploading, + } ); + } + ); + const isAnySitePushing = useMemo< boolean >( () => { return Object.values( pushStates ).some( ( state ) => isKeyPushing( state.status.key ) ); }, [ pushStates, isKeyPushing ] ); diff --git a/src/hooks/use-sync-states-progress-info.ts b/src/hooks/use-sync-states-progress-info.ts index 1057ed7f2..4864b40b0 100644 --- a/src/hooks/use-sync-states-progress-info.ts +++ b/src/hooks/use-sync-states-progress-info.ts @@ -16,7 +16,8 @@ export type PushStateProgressInfo = { | 'finishing' | 'finished' | 'failed' - | 'cancelled'; + | 'cancelled' + | 'uploadingPaused'; progress: number; message: string; }; @@ -105,6 +106,11 @@ export function useSyncStatesProgressInfo() { progress: 40, message: __( 'Uploading Studio site…' ), }, + uploadingPaused: { + key: 'uploadingPaused', + progress: 45, + message: __( 'Uploading paused' ), + }, creatingRemoteBackup: { key: 'creatingRemoteBackup', progress: 50, @@ -164,6 +170,10 @@ export function useSyncStatesProgressInfo() { return pushingStateKeys.includes( key ); }; + const isKeyUploadingPaused = ( key: PushStateProgressInfo[ 'key' ] | undefined ) => { + return key === 'uploadingPaused'; + }; + const isKeyImporting = ( key: PushStateProgressInfo[ 'key' ] | undefined ) => { const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ 'creatingRemoteBackup', @@ -296,5 +306,6 @@ export function useSyncStatesProgressInfo() { getBackupStatusWithProgress, getPullStatusWithProgress, getPushStatusWithProgress, + isKeyUploadingPaused, }; } diff --git a/src/ipc-utils.ts b/src/ipc-utils.ts index 7e1a91059..9893ac993 100644 --- a/src/ipc-utils.ts +++ b/src/ipc-utils.ts @@ -38,6 +38,8 @@ export interface IpcEvents { }, ]; 'site-context-menu-action': [ { action: string; siteId: string } ]; + 'sync-upload-paused': [ { error: string; selectedSiteId: string; remoteSiteId: number } ]; + 'sync-upload-resumed': [ { selectedSiteId: string; remoteSiteId: number } ]; 'snapshot-error': [ { operationId: crypto.UUID; data: SnapshotEventData } ]; 'snapshot-fatal-error': [ { operationId: crypto.UUID; data: { message: string } } ]; 'snapshot-output': [ { operationId: crypto.UUID; data: SnapshotEventData } ]; diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index fd2cb10de..2a6a2f48a 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -1,7 +1,7 @@ import { Icon } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; import { sprintf } from '@wordpress/i18n'; -import { cloudUpload, cloudDownload, info, close } from '@wordpress/icons'; +import { cloudUpload, cloudDownload, info, close, error } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useState } from 'react'; import { ArrowIcon } from 'src/components/arrow-icon'; @@ -199,6 +199,7 @@ const SyncConnectedSitesSectionItem = ( { isKeyFailed, isKeyCancelled, getPullStatusWithProgress, + isKeyUploadingPaused, } = useSyncStatesProgressInfo(); const sitePullState = getPullState( selectedSite.id, connectedSite.id ); @@ -211,6 +212,7 @@ const SyncConnectedSitesSectionItem = ( { const pushState = getPushState( selectedSite.id, connectedSite.id ); const isPushing = pushState && isKeyPushing( pushState.status.key ); + const isUploadingPaused = pushState && isKeyUploadingPaused( pushState.status.key ); const isPushError = pushState && isKeyFailed( pushState.status.key ); const hasPushFinished = pushState && isKeyFinished( pushState.status.key ); const hasPushCancelled = pushState && isKeyCancelled( pushState.status.key ); @@ -292,6 +294,19 @@ const SyncConnectedSitesSectionItem = ( { { __( 'Pull complete' ) } ) } + { pushState?.status && isUploadingPaused && ( + + + + ) } { pushState?.status && isPushing && (
( ( resolve, reject ) => { + const upload = new Upload( file, { + endpoint: `https://public-api.wordpress.com/rest/v1.1/studio-file-uploads/${ remoteSiteId }`, + chunkSize: 500000, + retryDelays: [ 0, 1000, 3000, 5000, 10000, 25000 ], + overridePatchMethod: true, + removeFingerprintOnSuccess: true, + storeFingerprintForResuming: true, + headers: { + Authorization: `Bearer ${ token.accessToken }`, + }, + metadata: { + filename, + filetype: 'application/gzip', + }, + uploadSize: fileSize, + onBeforeRequest: ( req ) => { + if ( req.getMethod() === 'HEAD' ) { + // @ts-expect-error We need to override the method to get the response headers. + req._method = 'GET'; + req.setHeader( 'X-HTTP-Method-Override', 'HEAD' ); + } + }, + onError: ( error ) => { + console.error( '[TUS] Upload error', error ); + reject( error ); + }, + onProgress: () => { + if ( isUploadingPaused ) { + isUploadingPaused = false; + void sendIpcEventToRenderer( 'sync-upload-resumed', { + selectedSiteId: selectedSiteId, + remoteSiteId: remoteSiteId, + } ); + console.log( '[TUS] Upload resumed' ); + } + + if ( ! hasUploadStarted ) { + hasUploadStarted = true; + } + }, + onSuccess: ( payload ) => { + if ( ! payload.lastResponse ) { + reject( new Error( 'Upload completed but no response received' ) ); + return; + } + + const attachmentId = payload.lastResponse.getHeader( 'x-studio-file-upload-media-id' ); + if ( attachmentId ) { + resolve( attachmentId ); + } else { + reject( new Error( 'Upload completed but required header not found' ) ); + } + }, + onShouldRetry: ( error ) => { + // Update the UI only if the upload has started and is paused for any reason. + if ( hasUploadStarted ) { + isUploadingPaused = true; + void sendIpcEventToRenderer( 'sync-upload-paused', { + selectedSiteId: selectedSiteId, + remoteSiteId: remoteSiteId, + error: error.message, + } ); + console.error( '[TUS] Upload paused: ', error.message ); + } + + const status = error.originalResponse ? error.originalResponse.getStatus() : 0; + // Stop retrying if the upload failed because of a 403 error. + if ( status === 403 ) { + return false; + } + + return true; + }, + } ); + + upload.start(); + } ).finally( () => { + file.destroy(); + file.close(); + } ); + + const attachmentId = await attachmentPromise; const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest ); const formData: [ string, unknown, Record< string, string >? ][] = [ - [ - 'import', - fs.createReadStream( archivePath ), - { - filename: 'loca-env-site-1.tar.gz', - contentType: 'application/gzip', - }, - ], + [ 'import_attachment_id', attachmentId ], ]; if ( specificSelectionPaths && specificSelectionPaths.length > 0 ) { @@ -181,7 +266,7 @@ export async function pushArchive( try { await wpcom.req.post( { - path: `/sites/${ remoteSiteId }/studio-app/sync/import`, + path: `/sites/${ remoteSiteId }/studio-app/sync/import/initiate`, apiNamespace: 'wpcom/v2', formData, } ); diff --git a/src/preload.ts b/src/preload.ts index 8201e2f8b..df6b1e963 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -23,9 +23,16 @@ function ipcRendererSend< T extends keyof IpcHandlers >( const api: IpcApi = { exportSiteForPush: ( id, operationId, configuration ) => ipcRendererInvoke( 'exportSiteForPush', id, operationId, configuration ), - pushArchive: ( remoteSiteId, archivePath, optionsToSync, specificSelectionPaths ) => + pushArchive: ( + selectedSiteId, + remoteSiteId, + archivePath, + optionsToSync, + specificSelectionPaths + ) => ipcRendererInvoke( 'pushArchive', + selectedSiteId, remoteSiteId, archivePath, optionsToSync,