watchdog.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module watchdog/watchdog
  7. */
  8. /* globals console, window */
  9. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  10. import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
  11. import { throttle, cloneDeepWith, isElement } from 'lodash-es';
  12. import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  13. import areConnectedThroughProperties from '@ckeditor/ckeditor5-utils/src/areconnectedthroughproperties';
  14. /**
  15. * A watchdog for CKEditor 5 editors.
  16. *
  17. * See the {@glink features/watchdog Watchdog} feature guide to learn the rationale behind it and
  18. * how to use it.
  19. */
  20. export default class Watchdog {
  21. /**
  22. * @param {module:watchdog/watchdog~WatchdogConfig} [config] The watchdog plugin configuration.
  23. */
  24. constructor( config = {} ) {
  25. /**
  26. * An array of crashes saved as an object with the following properties:
  27. *
  28. * * `message`: `String`,
  29. * * `stack`: `String`,
  30. * * `date`: `Number`,
  31. * * `filename`: `String | undefined`,
  32. * * `lineno`: `Number | undefined`,
  33. * * `colno`: `Number | undefined`,
  34. *
  35. * @public
  36. * @readonly
  37. * @type {Array.<Object>}
  38. */
  39. this.crashes = [];
  40. /**
  41. * Specifies the state of the editor handled by the watchdog. The state can be one of the following values:
  42. *
  43. * * `initializing` - before the first initialization, and after crashes, before the editor is ready,
  44. * * `ready` - a state when a user can interact with the editor,
  45. * * `crashed` - a state when an error occurs - it quickly changes to `initializing` or `crashedPermanently`
  46. * depending on how many and how frequency errors have been caught recently,
  47. * * `crashedPermanently` - a state when the watchdog stops reacting to errors and keeps the editor crashed,
  48. * * `destroyed` - a state when the editor is manually destroyed by the user after calling `watchdog.destroy()`
  49. *
  50. * @public
  51. * @observable
  52. * @member {'initializing'|'ready'|'crashed'|'crashedPermanently'|'destroyed'} #state
  53. */
  54. this.set( 'state', 'initializing' );
  55. /**
  56. * @private
  57. * @type {Number}
  58. * @see module:watchdog/watchdog~WatchdogConfig
  59. */
  60. this._crashNumberLimit = typeof config.crashNumberLimit === 'number' ? config.crashNumberLimit : 3;
  61. /**
  62. * Returns the result of `Date.now()` call. It can be overridden in tests to mock time as the popular
  63. * approaches like `sinon.useFakeTimers()` does not work well with error handling.
  64. *
  65. * @protected
  66. */
  67. this._now = Date.now;
  68. /**
  69. * @private
  70. * @type {Number}
  71. * @see module:watchdog/watchdog~WatchdogConfig
  72. */
  73. this._minimumNonErrorTimePeriod = typeof config.minimumNonErrorTimePeriod === 'number' ? config.minimumNonErrorTimePeriod : 5000;
  74. /**
  75. * Checks if the event error comes from the editor that is handled by the watchdog (by checking the error context)
  76. * and restarts the editor.
  77. *
  78. * @private
  79. * @type {Function}
  80. */
  81. this._boundErrorHandler = evt => {
  82. // `evt.error` is exposed by EventError while `evt.reason` is available in PromiseRejectionEvent.
  83. if ( evt.reason ) {
  84. // Note that evt.reason might be everything that is in the promise rejection.
  85. if ( evt.reason instanceof Error ) {
  86. this._handleError( evt.reason, evt );
  87. }
  88. } else {
  89. this._handleError( evt.error, evt );
  90. }
  91. };
  92. /**
  93. * Throttled save method. The `save()` method is called the specified `saveInterval` after `throttledSave()` is called,
  94. * unless a new action happens in the meantime.
  95. *
  96. * @private
  97. * @type {Function}
  98. */
  99. this._throttledSave = throttle(
  100. this._save.bind( this ),
  101. typeof config.saveInterval === 'number' ? config.saveInterval : 5000
  102. );
  103. /**
  104. * The current editor instance.
  105. *
  106. * @private
  107. * @type {module:core/editor/editor~Editor}
  108. */
  109. this._editor = null;
  110. /**
  111. * The editor creation method.
  112. *
  113. * @private
  114. * @member {Function} #_creator
  115. * @see #setCreator
  116. */
  117. /**
  118. * The editor destruction method.
  119. *
  120. * @private
  121. * @member {Function} #_destructor
  122. * @see #setDestructor
  123. */
  124. /**
  125. * The latest saved editor data represented as a root name -> root data object.
  126. *
  127. * @private
  128. * @member {Object.<String,String>} #_data
  129. */
  130. /**
  131. * The last document version.
  132. *
  133. * @private
  134. * @member {Number} #_lastDocumentVersion
  135. */
  136. /**
  137. * The editor source element or data.
  138. *
  139. * @private
  140. * @member {HTMLElement|String} #_elementOrData
  141. */
  142. /**
  143. * The editor configuration.
  144. *
  145. * @private
  146. * @member {Object|undefined} #_config
  147. */
  148. }
  149. /**
  150. * The current editor instance.
  151. *
  152. * @readonly
  153. * @type {module:core/editor/editor~Editor}
  154. */
  155. get editor() {
  156. return this._editor;
  157. }
  158. /**
  159. * Sets the function that is responsible for editor creation.
  160. * It expects a function that should return a promise.
  161. *
  162. * watchdog.setCreator( ( element, config ) => ClassicEditor.create( element, config ) );
  163. *
  164. * @param {Function} creator
  165. */
  166. setCreator( creator ) {
  167. this._creator = creator;
  168. }
  169. /**
  170. * Sets the function that is responsible for editor destruction.
  171. * It expects a function that should return a promise or `undefined`.
  172. *
  173. * watchdog.setDestructor( editor => editor.destroy() );
  174. *
  175. * @param {Function} destructor
  176. */
  177. setDestructor( destructor ) {
  178. this._destructor = destructor;
  179. }
  180. /**
  181. * Creates a watched editor instance using the creator passed to the {@link #setCreator `setCreator()`} method or
  182. * {@link module:watchdog/watchdog~Watchdog.for `Watchdog.for()`} helper.
  183. *
  184. * @param {HTMLElement|String} elementOrData
  185. * @param {module:core/editor/editorconfig~EditorConfig} [config]
  186. *
  187. * @returns {Promise}
  188. */
  189. create( elementOrData, config ) {
  190. if ( !this._creator ) {
  191. /**
  192. * The watchdog's editor creator is not defined. Define it by using
  193. * {@link module:watchdog/watchdog~Watchdog#setCreator `Watchdog#setCreator()`} or
  194. * the {@link module:watchdog/watchdog~Watchdog.for `Watchdog.for()`} helper.
  195. *
  196. * @error watchdog-creator-not-defined
  197. */
  198. throw new CKEditorError(
  199. 'watchdog-creator-not-defined: The watchdog\'s editor creator is not defined.',
  200. null
  201. );
  202. }
  203. if ( !this._destructor ) {
  204. /**
  205. * The watchdog's editor destructor is not defined. Define it by using
  206. * {@link module:watchdog/watchdog~Watchdog#setDestructor `Watchdog#setDestructor()`} or
  207. * the {@link module:watchdog/watchdog~Watchdog.for `Watchdog.for()`} helper.
  208. *
  209. * @error watchdog-destructor-not-defined
  210. */
  211. throw new CKEditorError(
  212. 'watchdog-destructor-not-defined: The watchdog\'s editor destructor is not defined.',
  213. null
  214. );
  215. }
  216. this._elementOrData = elementOrData;
  217. // Clone config because it might be shared within multiple watchdog instances. Otherwise
  218. // when an error occurs in one of these editors the watchdog will restart all of them.
  219. this._config = cloneDeepWith( config, value => {
  220. // Leave DOM references.
  221. return isElement( value ) ? value : undefined;
  222. } );
  223. return Promise.resolve()
  224. .then( () => this._creator( elementOrData, this._config ) )
  225. .then( editor => {
  226. this._editor = editor;
  227. window.addEventListener( 'error', this._boundErrorHandler );
  228. window.addEventListener( 'unhandledrejection', this._boundErrorHandler );
  229. this.listenTo( editor.model.document, 'change:data', this._throttledSave );
  230. this._lastDocumentVersion = editor.model.document.version;
  231. this._data = this._getData();
  232. this.state = 'ready';
  233. } );
  234. }
  235. /**
  236. * Destroys the current editor instance by using the destructor passed to the {@link #setDestructor `setDestructor()`} method
  237. * and sets state to `destroyed`.
  238. *
  239. * @returns {Promise}
  240. */
  241. destroy() {
  242. this.state = 'destroyed';
  243. return this._destroy();
  244. }
  245. /**
  246. * Destroys the current editor instance by using the destructor passed to the {@link #setDestructor `setDestructor()`} method.
  247. *
  248. * @private
  249. */
  250. _destroy() {
  251. window.removeEventListener( 'error', this._boundErrorHandler );
  252. window.removeEventListener( 'unhandledrejection', this._boundErrorHandler );
  253. this.stopListening( this._editor.model.document, 'change:data', this._throttledSave );
  254. // Save data if there is a remaining editor data change.
  255. this._throttledSave.flush();
  256. return Promise.resolve()
  257. .then( () => this._destructor( this._editor ) )
  258. .then( () => {
  259. this._editor = null;
  260. } );
  261. }
  262. /**
  263. * Saves the editor data, so it can be restored after the crash even if the data cannot be fetched at
  264. * the moment of a crash.
  265. *
  266. * @private
  267. */
  268. _save() {
  269. const version = this._editor.model.document.version;
  270. // Change may not produce an operation, so the document's version
  271. // can be the same after that change.
  272. if ( version === this._lastDocumentVersion ) {
  273. return;
  274. }
  275. try {
  276. this._data = this._getData();
  277. this._lastDocumentVersion = version;
  278. } catch ( err ) {
  279. console.error(
  280. err,
  281. 'An error happened during restoring editor data. ' +
  282. 'Editor will be restored from the previously saved data.'
  283. );
  284. }
  285. }
  286. /**
  287. * Returns the editor data.
  288. *
  289. * @private
  290. * @returns {Object<String,String>}
  291. */
  292. _getData() {
  293. const data = {};
  294. for ( const rootName of this._editor.model.document.getRootNames() ) {
  295. data[ rootName ] = this._editor.data.get( { rootName } );
  296. }
  297. return data;
  298. }
  299. /**
  300. * Checks if the error comes from the editor that is handled by the watchdog (by checking the error context) and
  301. * restarts the editor. It reacts to {@link module:utils/ckeditorerror~CKEditorError `CKEditorError` errors} only.
  302. *
  303. * @private
  304. * @fires error
  305. * @param {Error} error Error.
  306. * @param {ErrorEvent|PromiseRejectionEvent} evt Error event.
  307. */
  308. _handleError( error, evt ) {
  309. if ( error.is && error.is( 'CKEditorError' ) && error.context === undefined ) {
  310. console.error( 'The error is missing its context and Watchdog cannot restart the proper editor.' );
  311. }
  312. if ( this._shouldReactToError( error ) ) {
  313. this.crashes.push( {
  314. message: error.message,
  315. stack: error.stack,
  316. // `evt.filename`, `evt.lineno` and `evt.colno` are available only in ErrorEvent events
  317. filename: evt.filename,
  318. lineno: evt.lineno,
  319. colno: evt.colno,
  320. date: this._now()
  321. } );
  322. this.fire( 'error', { error } );
  323. this.state = 'crashed';
  324. if ( this._shouldRestartEditor() ) {
  325. this._restart();
  326. } else {
  327. this.state = 'crashedPermanently';
  328. }
  329. }
  330. }
  331. /**
  332. * Checks whether the error should be handled.
  333. *
  334. * @private
  335. * @param {Error} error Error
  336. */
  337. _shouldReactToError( error ) {
  338. return (
  339. error.is &&
  340. error.is( 'CKEditorError' ) &&
  341. error.context !== undefined &&
  342. // In some cases the editor should not be restarted - e.g. in case of the editor initialization.
  343. // That's why the `null` was introduced as a correct error context which does cause restarting.
  344. error.context !== null &&
  345. // Do not react to errors if the watchdog is in states other than `ready`.
  346. this.state === 'ready' &&
  347. this._isErrorComingFromThisEditor( error )
  348. );
  349. }
  350. /**
  351. * Checks if the editor should be restared or if it should be marked as crashed.
  352. */
  353. _shouldRestartEditor() {
  354. if ( this.crashes.length <= this._crashNumberLimit ) {
  355. return true;
  356. }
  357. const lastErrorTime = this.crashes[ this.crashes.length - 1 ].date;
  358. const firstMeaningfulErrorTime = this.crashes[ this.crashes.length - 1 - this._crashNumberLimit ].date;
  359. const averageNonErrorTimePeriod = ( lastErrorTime - firstMeaningfulErrorTime ) / this._crashNumberLimit;
  360. return averageNonErrorTimePeriod > this._minimumNonErrorTimePeriod;
  361. }
  362. /**
  363. * Restarts the editor instance. This method is called whenever an editor error occurs. It fires the `restart` event and changes
  364. * the state to `initializing`.
  365. *
  366. * @private
  367. * @fires restart
  368. * @returns {Promise}
  369. */
  370. _restart() {
  371. this.state = 'initializing';
  372. return Promise.resolve()
  373. .then( () => this._destroy() )
  374. .catch( err => console.error( 'An error happened during the editor destructing.', err ) )
  375. .then( () => {
  376. if ( typeof this._elementOrData === 'string' ) {
  377. return this.create( this._data, this._config );
  378. }
  379. const updatedConfig = Object.assign( {}, this._config, {
  380. initialData: this._data
  381. } );
  382. return this.create( this._elementOrData, updatedConfig );
  383. } )
  384. .then( () => {
  385. this.fire( 'restart' );
  386. } );
  387. }
  388. /**
  389. * Traverses both structures to find out whether the error context is connected
  390. * with the current editor.
  391. *
  392. * @private
  393. * @param {module:utils/ckeditorerror~CKEditorError} error
  394. */
  395. _isErrorComingFromThisEditor( error ) {
  396. return areConnectedThroughProperties( this._editor, error.context );
  397. }
  398. /**
  399. * A shorthand method for creating an instance of the watchdog. For the full usage see the
  400. * {@link ~Watchdog `Watchdog` class description}.
  401. *
  402. * Usage:
  403. *
  404. * const watchdog = Watchdog.for( ClassicEditor );
  405. *
  406. * watchdog.create( elementOrData, config );
  407. *
  408. * @param {*} Editor The editor class.
  409. * @param {module:watchdog/watchdog~WatchdogConfig} [watchdogConfig] The watchdog plugin configuration.
  410. */
  411. static for( Editor, watchdogConfig ) {
  412. const watchdog = new Watchdog( watchdogConfig );
  413. watchdog.setCreator( ( elementOrData, config ) => Editor.create( elementOrData, config ) );
  414. watchdog.setDestructor( editor => editor.destroy() );
  415. return watchdog;
  416. }
  417. /**
  418. * Fired when a new {@link module:utils/ckeditorerror~CKEditorError `CKEditorError`} error connected to the watchdog editor occurs
  419. * and the watchdog will react to it.
  420. *
  421. * @event error
  422. */
  423. /**
  424. * Fired after the watchdog restarts the error in case of a crash.
  425. *
  426. * @event restart
  427. */
  428. }
  429. mix( Watchdog, ObservableMixin );
  430. /**
  431. * The watchdog plugin configuration.
  432. *
  433. * @typedef {Object} WatchdogConfig
  434. *
  435. * @property {Number} [crashNumberLimit=3] A threshold specifying the number of editor errors (defaults to `3`).
  436. * After this limit is reached and the time between last errors is shorter than `minimumNonErrorTimePeriod`
  437. * the watchdog changes its state to `crashedPermanently` and it stops restarting the editor. This prevents an infinite restart loop.
  438. * @property {Number} [minimumNonErrorTimePeriod=5000] An average amount of milliseconds between last editor errors
  439. * (defaults to 5000). When the period of time between errors is lower than that and the `crashNumberLimit` is also reached
  440. * the watchdog changes its state to `crashedPermanently` and it stops restarting the editor. This prevents an infinite restart loop.
  441. * @property {Number} [saveInterval=5000] A minimum number of milliseconds between saving editor data internally, (defaults to 5000).
  442. * Note that for large documents this might have an impact on the editor performance.
  443. */