You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

пре 3 година
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749
  1. /**
  2. * Heartbeat API
  3. *
  4. * Heartbeat is a simple server polling API that sends XHR requests to
  5. * the server every 15 - 60 seconds and triggers events (or callbacks) upon
  6. * receiving data. Currently these 'ticks' handle transports for post locking,
  7. * login-expiration warnings, autosave, and related tasks while a user is logged in.
  8. *
  9. * Available PHP filters (in ajax-actions.php):
  10. * - heartbeat_received
  11. * - heartbeat_send
  12. * - heartbeat_tick
  13. * - heartbeat_nopriv_received
  14. * - heartbeat_nopriv_send
  15. * - heartbeat_nopriv_tick
  16. * @see wp_ajax_nopriv_heartbeat(), wp_ajax_heartbeat()
  17. *
  18. * Custom jQuery events:
  19. * - heartbeat-send
  20. * - heartbeat-tick
  21. * - heartbeat-error
  22. * - heartbeat-connection-lost
  23. * - heartbeat-connection-restored
  24. * - heartbeat-nonces-expired
  25. *
  26. * @since 3.6.0
  27. */
  28. ( function( $, window, undefined ) {
  29. var Heartbeat = function() {
  30. var $document = $(document),
  31. settings = {
  32. // Suspend/resume
  33. suspend: false,
  34. // Whether suspending is enabled
  35. suspendEnabled: true,
  36. // Current screen id, defaults to the JS global 'pagenow' when present (in the admin) or 'front'
  37. screenId: '',
  38. // XHR request URL, defaults to the JS global 'ajaxurl' when present
  39. url: '',
  40. // Timestamp, start of the last connection request
  41. lastTick: 0,
  42. // Container for the enqueued items
  43. queue: {},
  44. // Connect interval (in seconds)
  45. mainInterval: 60,
  46. // Used when the interval is set to 5 sec. temporarily
  47. tempInterval: 0,
  48. // Used when the interval is reset
  49. originalInterval: 0,
  50. // Used to limit the number of AJAX requests.
  51. minimalInterval: 0,
  52. // Used together with tempInterval
  53. countdown: 0,
  54. // Whether a connection is currently in progress
  55. connecting: false,
  56. // Whether a connection error occurred
  57. connectionError: false,
  58. // Used to track non-critical errors
  59. errorcount: 0,
  60. // Whether at least one connection has completed successfully
  61. hasConnected: false,
  62. // Whether the current browser window is in focus and the user is active
  63. hasFocus: true,
  64. // Timestamp, last time the user was active. Checked every 30 sec.
  65. userActivity: 0,
  66. // Flags whether events tracking user activity were set
  67. userActivityEvents: false,
  68. checkFocusTimer: 0,
  69. beatTimer: 0
  70. };
  71. /**
  72. * Set local vars and events, then start
  73. *
  74. * @access private
  75. *
  76. * @return void
  77. */
  78. function initialize() {
  79. var options, hidden, visibilityState, visibilitychange;
  80. if ( typeof window.pagenow === 'string' ) {
  81. settings.screenId = window.pagenow;
  82. }
  83. if ( typeof window.ajaxurl === 'string' ) {
  84. settings.url = window.ajaxurl;
  85. }
  86. // Pull in options passed from PHP
  87. if ( typeof window.heartbeatSettings === 'object' ) {
  88. options = window.heartbeatSettings;
  89. // The XHR URL can be passed as option when window.ajaxurl is not set
  90. if ( ! settings.url && options.ajaxurl ) {
  91. settings.url = options.ajaxurl;
  92. }
  93. // The interval can be from 15 to 120 sec. and can be set temporarily to 5 sec.
  94. // It can be set in the initial options or changed later from JS and/or from PHP.
  95. if ( options.interval ) {
  96. settings.mainInterval = options.interval;
  97. if ( settings.mainInterval < 15 ) {
  98. settings.mainInterval = 15;
  99. } else if ( settings.mainInterval > 120 ) {
  100. settings.mainInterval = 120;
  101. }
  102. }
  103. // Used to limit the number of AJAX requests. Overrides all other intervals if they are shorter.
  104. // Needed for some hosts that cannot handle frequent requests and the user may exceed the allocated server CPU time, etc.
  105. // The minimal interval can be up to 600 sec. however setting it to longer than 120 sec. will limit or disable
  106. // some of the functionality (like post locks).
  107. // Once set at initialization, minimalInterval cannot be changed/overridden.
  108. if ( options.minimalInterval ) {
  109. options.minimalInterval = parseInt( options.minimalInterval, 10 );
  110. settings.minimalInterval = options.minimalInterval > 0 && options.minimalInterval <= 600 ? options.minimalInterval * 1000 : 0;
  111. }
  112. if ( settings.minimalInterval && settings.mainInterval < settings.minimalInterval ) {
  113. settings.mainInterval = settings.minimalInterval;
  114. }
  115. // 'screenId' can be added from settings on the front end where the JS global 'pagenow' is not set
  116. if ( ! settings.screenId ) {
  117. settings.screenId = options.screenId || 'front';
  118. }
  119. if ( options.suspension === 'disable' ) {
  120. settings.suspendEnabled = false;
  121. }
  122. }
  123. // Convert to milliseconds
  124. settings.mainInterval = settings.mainInterval * 1000;
  125. settings.originalInterval = settings.mainInterval;
  126. // Switch the interval to 120 sec. by using the Page Visibility API.
  127. // If the browser doesn't support it (Safari < 7, Android < 4.4, IE < 10), the interval
  128. // will be increased to 120 sec. after 5 min. of mouse and keyboard inactivity.
  129. if ( typeof document.hidden !== 'undefined' ) {
  130. hidden = 'hidden';
  131. visibilitychange = 'visibilitychange';
  132. visibilityState = 'visibilityState';
  133. } else if ( typeof document.msHidden !== 'undefined' ) { // IE10
  134. hidden = 'msHidden';
  135. visibilitychange = 'msvisibilitychange';
  136. visibilityState = 'msVisibilityState';
  137. } else if ( typeof document.webkitHidden !== 'undefined' ) { // Android
  138. hidden = 'webkitHidden';
  139. visibilitychange = 'webkitvisibilitychange';
  140. visibilityState = 'webkitVisibilityState';
  141. }
  142. if ( hidden ) {
  143. if ( document[hidden] ) {
  144. settings.hasFocus = false;
  145. }
  146. $document.on( visibilitychange + '.wp-heartbeat', function() {
  147. if ( document[visibilityState] === 'hidden' ) {
  148. blurred();
  149. window.clearInterval( settings.checkFocusTimer );
  150. } else {
  151. focused();
  152. if ( document.hasFocus ) {
  153. settings.checkFocusTimer = window.setInterval( checkFocus, 10000 );
  154. }
  155. }
  156. });
  157. }
  158. // Use document.hasFocus() if available.
  159. if ( document.hasFocus ) {
  160. settings.checkFocusTimer = window.setInterval( checkFocus, 10000 );
  161. }
  162. $(window).on( 'unload.wp-heartbeat', function() {
  163. // Don't connect any more
  164. settings.suspend = true;
  165. // Abort the last request if not completed
  166. if ( settings.xhr && settings.xhr.readyState !== 4 ) {
  167. settings.xhr.abort();
  168. }
  169. });
  170. // Check for user activity every 30 seconds.
  171. window.setInterval( checkUserActivity, 30000 );
  172. // Start one tick after DOM ready
  173. $document.ready( function() {
  174. settings.lastTick = time();
  175. scheduleNextTick();
  176. });
  177. }
  178. /**
  179. * Return the current time according to the browser
  180. *
  181. * @access private
  182. *
  183. * @return int
  184. */
  185. function time() {
  186. return (new Date()).getTime();
  187. }
  188. /**
  189. * Check if the iframe is from the same origin
  190. *
  191. * @access private
  192. *
  193. * @return bool
  194. */
  195. function isLocalFrame( frame ) {
  196. var origin, src = frame.src;
  197. // Need to compare strings as WebKit doesn't throw JS errors when iframes have different origin.
  198. // It throws uncatchable exceptions.
  199. if ( src && /^https?:\/\//.test( src ) ) {
  200. origin = window.location.origin ? window.location.origin : window.location.protocol + '//' + window.location.host;
  201. if ( src.indexOf( origin ) !== 0 ) {
  202. return false;
  203. }
  204. }
  205. try {
  206. if ( frame.contentWindow.document ) {
  207. return true;
  208. }
  209. } catch(e) {}
  210. return false;
  211. }
  212. /**
  213. * Check if the document's focus has changed
  214. *
  215. * @access private
  216. *
  217. * @return void
  218. */
  219. function checkFocus() {
  220. if ( settings.hasFocus && ! document.hasFocus() ) {
  221. blurred();
  222. } else if ( ! settings.hasFocus && document.hasFocus() ) {
  223. focused();
  224. }
  225. }
  226. /**
  227. * Set error state and fire an event on XHR errors or timeout
  228. *
  229. * @access private
  230. *
  231. * @param string error The error type passed from the XHR
  232. * @param int status The HTTP status code passed from jqXHR (200, 404, 500, etc.)
  233. * @return void
  234. */
  235. function setErrorState( error, status ) {
  236. var trigger;
  237. if ( error ) {
  238. switch ( error ) {
  239. case 'abort':
  240. // do nothing
  241. break;
  242. case 'timeout':
  243. // no response for 30 sec.
  244. trigger = true;
  245. break;
  246. case 'error':
  247. if ( 503 === status && settings.hasConnected ) {
  248. trigger = true;
  249. break;
  250. }
  251. /* falls through */
  252. case 'parsererror':
  253. case 'empty':
  254. case 'unknown':
  255. settings.errorcount++;
  256. if ( settings.errorcount > 2 && settings.hasConnected ) {
  257. trigger = true;
  258. }
  259. break;
  260. }
  261. if ( trigger && ! hasConnectionError() ) {
  262. settings.connectionError = true;
  263. $document.trigger( 'heartbeat-connection-lost', [error, status] );
  264. }
  265. }
  266. }
  267. /**
  268. * Clear the error state and fire an event
  269. *
  270. * @access private
  271. *
  272. * @return void
  273. */
  274. function clearErrorState() {
  275. // Has connected successfully
  276. settings.hasConnected = true;
  277. if ( hasConnectionError() ) {
  278. settings.errorcount = 0;
  279. settings.connectionError = false;
  280. $document.trigger( 'heartbeat-connection-restored' );
  281. }
  282. }
  283. /**
  284. * Gather the data and connect to the server
  285. *
  286. * @access private
  287. *
  288. * @return void
  289. */
  290. function connect() {
  291. var ajaxData, heartbeatData;
  292. // If the connection to the server is slower than the interval,
  293. // heartbeat connects as soon as the previous connection's response is received.
  294. if ( settings.connecting || settings.suspend ) {
  295. return;
  296. }
  297. settings.lastTick = time();
  298. heartbeatData = $.extend( {}, settings.queue );
  299. // Clear the data queue, anything added after this point will be send on the next tick
  300. settings.queue = {};
  301. $document.trigger( 'heartbeat-send', [ heartbeatData ] );
  302. ajaxData = {
  303. data: heartbeatData,
  304. interval: settings.tempInterval ? settings.tempInterval / 1000 : settings.mainInterval / 1000,
  305. _nonce: typeof window.heartbeatSettings === 'object' ? window.heartbeatSettings.nonce : '',
  306. action: 'heartbeat',
  307. screen_id: settings.screenId,
  308. has_focus: settings.hasFocus
  309. };
  310. settings.connecting = true;
  311. settings.xhr = $.ajax({
  312. url: settings.url,
  313. type: 'post',
  314. timeout: 30000, // throw an error if not completed after 30 sec.
  315. data: ajaxData,
  316. dataType: 'json'
  317. }).always( function() {
  318. settings.connecting = false;
  319. scheduleNextTick();
  320. }).done( function( response, textStatus, jqXHR ) {
  321. var newInterval;
  322. if ( ! response ) {
  323. setErrorState( 'empty' );
  324. return;
  325. }
  326. clearErrorState();
  327. if ( response.nonces_expired ) {
  328. $document.trigger( 'heartbeat-nonces-expired' );
  329. }
  330. // Change the interval from PHP
  331. if ( response.heartbeat_interval ) {
  332. newInterval = response.heartbeat_interval;
  333. delete response.heartbeat_interval;
  334. }
  335. $document.trigger( 'heartbeat-tick', [response, textStatus, jqXHR] );
  336. // Do this last, can trigger the next XHR if connection time > 5 sec. and newInterval == 'fast'
  337. if ( newInterval ) {
  338. interval( newInterval );
  339. }
  340. }).fail( function( jqXHR, textStatus, error ) {
  341. setErrorState( textStatus || 'unknown', jqXHR.status );
  342. $document.trigger( 'heartbeat-error', [jqXHR, textStatus, error] );
  343. });
  344. }
  345. /**
  346. * Schedule the next connection
  347. *
  348. * Fires immediately if the connection time is longer than the interval.
  349. *
  350. * @access private
  351. *
  352. * @return void
  353. */
  354. function scheduleNextTick() {
  355. var delta = time() - settings.lastTick,
  356. interval = settings.mainInterval;
  357. if ( settings.suspend ) {
  358. return;
  359. }
  360. if ( ! settings.hasFocus ) {
  361. interval = 120000; // 120 sec. Post locks expire after 150 sec.
  362. } else if ( settings.countdown > 0 && settings.tempInterval ) {
  363. interval = settings.tempInterval;
  364. settings.countdown--;
  365. if ( settings.countdown < 1 ) {
  366. settings.tempInterval = 0;
  367. }
  368. }
  369. if ( settings.minimalInterval && interval < settings.minimalInterval ) {
  370. interval = settings.minimalInterval;
  371. }
  372. window.clearTimeout( settings.beatTimer );
  373. if ( delta < interval ) {
  374. settings.beatTimer = window.setTimeout(
  375. function() {
  376. connect();
  377. },
  378. interval - delta
  379. );
  380. } else {
  381. connect();
  382. }
  383. }
  384. /**
  385. * Set the internal state when the browser window becomes hidden or loses focus
  386. *
  387. * @access private
  388. *
  389. * @return void
  390. */
  391. function blurred() {
  392. settings.hasFocus = false;
  393. }
  394. /**
  395. * Set the internal state when the browser window becomes visible or is in focus
  396. *
  397. * @access private
  398. *
  399. * @return void
  400. */
  401. function focused() {
  402. settings.userActivity = time();
  403. // Resume if suspended
  404. settings.suspend = false;
  405. if ( ! settings.hasFocus ) {
  406. settings.hasFocus = true;
  407. scheduleNextTick();
  408. }
  409. }
  410. /**
  411. * Runs when the user becomes active after a period of inactivity
  412. *
  413. * @access private
  414. *
  415. * @return void
  416. */
  417. function userIsActive() {
  418. settings.userActivityEvents = false;
  419. $document.off( '.wp-heartbeat-active' );
  420. $('iframe').each( function( i, frame ) {
  421. if ( isLocalFrame( frame ) ) {
  422. $( frame.contentWindow ).off( '.wp-heartbeat-active' );
  423. }
  424. });
  425. focused();
  426. }
  427. /**
  428. * Check for user activity
  429. *
  430. * Runs every 30 sec.
  431. * Sets 'hasFocus = true' if user is active and the window is in the background.
  432. * Set 'hasFocus = false' if the user has been inactive (no mouse or keyboard activity)
  433. * for 5 min. even when the window has focus.
  434. *
  435. * @access private
  436. *
  437. * @return void
  438. */
  439. function checkUserActivity() {
  440. var lastActive = settings.userActivity ? time() - settings.userActivity : 0;
  441. // Throttle down when no mouse or keyboard activity for 5 min.
  442. if ( lastActive > 300000 && settings.hasFocus ) {
  443. blurred();
  444. }
  445. // Suspend after 10 min. of inactivity when suspending is enabled.
  446. // Always suspend after 60 min. of inactivity. This will release the post lock, etc.
  447. if ( ( settings.suspendEnabled && lastActive > 600000 ) || lastActive > 3600000 ) {
  448. settings.suspend = true;
  449. }
  450. if ( ! settings.userActivityEvents ) {
  451. $document.on( 'mouseover.wp-heartbeat-active keyup.wp-heartbeat-active touchend.wp-heartbeat-active', function() {
  452. userIsActive();
  453. });
  454. $('iframe').each( function( i, frame ) {
  455. if ( isLocalFrame( frame ) ) {
  456. $( frame.contentWindow ).on( 'mouseover.wp-heartbeat-active keyup.wp-heartbeat-active touchend.wp-heartbeat-active', function() {
  457. userIsActive();
  458. });
  459. }
  460. });
  461. settings.userActivityEvents = true;
  462. }
  463. }
  464. // Public methods
  465. /**
  466. * Whether the window (or any local iframe in it) has focus, or the user is active
  467. *
  468. * @return bool
  469. */
  470. function hasFocus() {
  471. return settings.hasFocus;
  472. }
  473. /**
  474. * Whether there is a connection error
  475. *
  476. * @return bool
  477. */
  478. function hasConnectionError() {
  479. return settings.connectionError;
  480. }
  481. /**
  482. * Connect asap regardless of 'hasFocus'
  483. *
  484. * Will not open two concurrent connections. If a connection is in progress,
  485. * will connect again immediately after the current connection completes.
  486. *
  487. * @return void
  488. */
  489. function connectNow() {
  490. settings.lastTick = 0;
  491. scheduleNextTick();
  492. }
  493. /**
  494. * Disable suspending
  495. *
  496. * Should be used only when Heartbeat is performing critical tasks like autosave, post-locking, etc.
  497. * Using this on many screens may overload the user's hosting account if several
  498. * browser windows/tabs are left open for a long time.
  499. *
  500. * @return void
  501. */
  502. function disableSuspend() {
  503. settings.suspendEnabled = false;
  504. }
  505. /**
  506. * Get/Set the interval
  507. *
  508. * When setting to 'fast' or 5, by default interval is 5 sec. for the next 30 ticks (for 2 min and 30 sec).
  509. * In this case the number of 'ticks' can be passed as second argument.
  510. * If the window doesn't have focus, the interval slows down to 2 min.
  511. *
  512. * @param mixed speed Interval: 'fast' or 5, 15, 30, 60, 120
  513. * @param string ticks Used with speed = 'fast' or 5, how many ticks before the interval reverts back
  514. * @return int Current interval in seconds
  515. */
  516. function interval( speed, ticks ) {
  517. var newInterval,
  518. oldInterval = settings.tempInterval ? settings.tempInterval : settings.mainInterval;
  519. if ( speed ) {
  520. switch ( speed ) {
  521. case 'fast':
  522. case 5:
  523. newInterval = 5000;
  524. break;
  525. case 15:
  526. newInterval = 15000;
  527. break;
  528. case 30:
  529. newInterval = 30000;
  530. break;
  531. case 60:
  532. newInterval = 60000;
  533. break;
  534. case 120:
  535. newInterval = 120000;
  536. break;
  537. case 'long-polling':
  538. // Allow long polling, (experimental)
  539. settings.mainInterval = 0;
  540. return 0;
  541. default:
  542. newInterval = settings.originalInterval;
  543. }
  544. if ( settings.minimalInterval && newInterval < settings.minimalInterval ) {
  545. newInterval = settings.minimalInterval;
  546. }
  547. if ( 5000 === newInterval ) {
  548. ticks = parseInt( ticks, 10 ) || 30;
  549. ticks = ticks < 1 || ticks > 30 ? 30 : ticks;
  550. settings.countdown = ticks;
  551. settings.tempInterval = newInterval;
  552. } else {
  553. settings.countdown = 0;
  554. settings.tempInterval = 0;
  555. settings.mainInterval = newInterval;
  556. }
  557. // Change the next connection time if new interval has been set.
  558. // Will connect immediately if the time since the last connection
  559. // is greater than the new interval.
  560. if ( newInterval !== oldInterval ) {
  561. scheduleNextTick();
  562. }
  563. }
  564. return settings.tempInterval ? settings.tempInterval / 1000 : settings.mainInterval / 1000;
  565. }
  566. /**
  567. * Enqueue data to send with the next XHR
  568. *
  569. * As the data is send asynchronously, this function doesn't return the XHR response.
  570. * To see the response, use the custom jQuery event 'heartbeat-tick' on the document, example:
  571. * $(document).on( 'heartbeat-tick.myname', function( event, data, textStatus, jqXHR ) {
  572. * // code
  573. * });
  574. * If the same 'handle' is used more than once, the data is not overwritten when the third argument is 'true'.
  575. * Use wp.heartbeat.isQueued('handle') to see if any data is already queued for that handle.
  576. *
  577. * $param string handle Unique handle for the data. The handle is used in PHP to receive the data.
  578. * $param mixed data The data to send.
  579. * $param bool noOverwrite Whether to overwrite existing data in the queue.
  580. * $return bool Whether the data was queued or not.
  581. */
  582. function enqueue( handle, data, noOverwrite ) {
  583. if ( handle ) {
  584. if ( noOverwrite && this.isQueued( handle ) ) {
  585. return false;
  586. }
  587. settings.queue[handle] = data;
  588. return true;
  589. }
  590. return false;
  591. }
  592. /**
  593. * Check if data with a particular handle is queued
  594. *
  595. * $param string handle The handle for the data
  596. * $return bool Whether some data is queued with this handle
  597. */
  598. function isQueued( handle ) {
  599. if ( handle ) {
  600. return settings.queue.hasOwnProperty( handle );
  601. }
  602. }
  603. /**
  604. * Remove data with a particular handle from the queue
  605. *
  606. * $param string handle The handle for the data
  607. * $return void
  608. */
  609. function dequeue( handle ) {
  610. if ( handle ) {
  611. delete settings.queue[handle];
  612. }
  613. }
  614. /**
  615. * Get data that was enqueued with a particular handle
  616. *
  617. * $param string handle The handle for the data
  618. * $return mixed The data or undefined
  619. */
  620. function getQueuedItem( handle ) {
  621. if ( handle ) {
  622. return this.isQueued( handle ) ? settings.queue[handle] : undefined;
  623. }
  624. }
  625. initialize();
  626. // Expose public methods
  627. return {
  628. hasFocus: hasFocus,
  629. connectNow: connectNow,
  630. disableSuspend: disableSuspend,
  631. interval: interval,
  632. hasConnectionError: hasConnectionError,
  633. enqueue: enqueue,
  634. dequeue: dequeue,
  635. isQueued: isQueued,
  636. getQueuedItem: getQueuedItem
  637. };
  638. };
  639. // Ensure the global `wp` object exists.
  640. window.wp = window.wp || {};
  641. window.wp.heartbeat = new Heartbeat();
  642. }( jQuery, window ));