@ckeditor/ckeditor5-watchdog 10.0.1 → 17.0.0

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