-
-
Notifications
You must be signed in to change notification settings - Fork 66
/
Copy pathservice-worker.js
1723 lines (1507 loc) · 73.2 KB
/
service-worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* LibResilient Service Worker.
*/
// initialize the LibResilientConfig array
//
// this also sets some sane defaults,
// which then can be modified via config.json
if (typeof self.LibResilientConfig !== 'object' || self.LibResilientConfig === null) {
self.LibResilientConfig = {
// how long do we wait before we decide that a plugin is unresponsive,
// and move on?
defaultPluginTimeout: 10000,
// how long should LibResilient wait before displaying the "still loading" screen
// to the user if the request mode is "navigate"?
//
// NOTICE: the still-loading screen is only used if this setting is > 0
// NOTICE: *and* there is a stashing plugin (normally, `cache`) configured and enabled in the config
// NOTICE: this is done to avoid loops -- otherwise, user would find themselves in a (manual, but still) loop
stillLoadingTimeout: 5000,
// plugins settings namespace
//
// this defines which plugins get loaded,
// and the order in which they are deployed to try to retrieve content
// assumption: plugin path = ./plugins/<plugin-name>.js
//
// this relies on JavaScript preserving the insertion order for properties
// https://stackoverflow.com/a/5525820
plugins: [{
name: 'fetch'
},{
name: 'cache'
}
],
// which components should be logged?
// this is an array of strings, components not listed here
// will have their debug output disabled
//
// by default, the service worker and all enabled plugins
// (so, all components that are used)
loggedComponents: [
'service-worker',
'fetch',
'cache'
],
// should we normalize query params?
//
// this usually makes sense: a request to example.com/?a=a&b=b is
// exactly equivalent to example.com/?b=b&a=a
//
// but in case a given website does something weird with query params...
// ..normalization can be disabled here
normalizeQueryParams: true,
// do we want to use content-based MIME type detection using an external library?
//
// some plugins (for example, based on IPFS), receive content without a content type,
// because the transport simply does not support it. so we need to find a way to
// figure out the content type on our own -- that's done by the guessMimeType() function
//
// it can do this based on the extension of the file being requested,
// but that is a limited, imperfect approach.
//
// it can also load an external library (currently that's `file-type`), and guess
// the content type based on it. this approach is more exact, works for paths that
// do not have an "extension", and works for many more MIME types than the alternative.
//
// however, it is also slower (content needs to be read, inspected, and compared to a lot
// of signatures), and relies on an external library that needs to be distributed along
// with LibResilient.
//
// so, since it is not needed in case of most plugins, it is disabled by default.
useMimeSniffingLibrary: false
}
}
/**
* internal logging facility
*
* component - name of the component being logged about
* if the component is not in the LibResilientConfig.loggedComponents array,
* message will not be displayed
* items - the rest of arguments will be passed to console.debug()
*/
self.log = function(component, ...items) {
if ( ('LibResilientConfig' in self) && ('loggedComponents' in self.LibResilientConfig) && (self.LibResilientConfig.loggedComponents != undefined)) {
if (self.LibResilientConfig.loggedComponents.indexOf(component) >= 0) {
console.debug(`LibResilient [COMMIT_UNKNOWN, ${component}] ::`, ...items)
}
}
}
// Map() of file extensions to MIME types for the guessing game below
// this is by no means complete, and focuses mainly on formats that
// are important on the Web
let ext_to_mime = new Map([
['htm', 'text/html'],
['html', 'text/html'],
['css', 'text/css'],
['js', 'text/javascript'],
['json', 'application/json'],
['svg', 'image/svg+xml'],
['ico', 'image/x-icon'],
['gif', 'image/gif'],
['png', 'image/png'],
['jpg', 'image/jpeg'],
['jpeg', 'image/jpeg'],
['jpe', 'image/jpeg'],
['jfif', 'image/jpeg'],
['pjpeg', 'image/jpeg'],
['pjp', 'image/jpeg'],
['webp', 'image/webp'],
['avi', 'video/avi'],
['mp4', 'video/mp4'],
['mp2', 'video/mpeg'],
['mp3', 'audio/mpeg'],
['mpa', 'video/mpeg'],
['pdf', 'application/pdf'],
['txt', 'text/plain'],
['ics', 'text/calendar'],
['jsonld', 'application/ld+json'],
['mjs', 'text/javascript'],
['oga', 'audio/ogg'],
['ogv', 'video/ogg'],
['ogx', 'application/ogg'],
['opus', 'audio/opus'],
['otf', 'font/otf'],
['ts', 'video/mp2t'],
['ttf', 'font/ttf'],
['weba', 'audio/webm'],
['webm', 'video/webm'],
['webp', 'image/webp'],
['woff', 'font/woff'],
['woff2', 'font/woff2'],
['xhtml', 'application/xhtml+xml'],
['xml', 'application/xml']
])
// preparing the variable for the MIME detection module
// in case we want to use it
let detectMimeFromBuffer = null
/**
* guess the MIME type, based on content and path extension
*
* important: according to RFC 7231 we should not set Content-Type if we're not sure!
* https://www.rfc-editor.org/rfc/rfc7231#section-3.1.1.5
*
* @param ext - the extension of the path content was fetched as
* @param content - the content itself
* @returns string containing the MIME type, or empty string if guessing failed
*/
self.guessMimeType = async function(ext, content) {
// if we have file-type library loaded, that means that useMimeSniffingLibrary config field is set to true
// and that we were able to load file-type.js
//
// in other words, we want to use it, we can use it -- so use it!
if (detectMimeFromBuffer !== null) {
let ft = undefined
try {
ft = await detectMimeFromBuffer(content)
} catch (e) {
self.log('service-worker', "+-- error while trying to guess MIME type based on content:", e);
}
// did we actually get anything?
if ( (ft !== undefined) && (typeof ft === "object") && ("mime" in ft) ) {
// yup!
self.log('service-worker', "+-- guessed MIME type based on content: " + ft.mime);
return ft.mime;
} else {
self.log('service-worker', "+-- unable to guess MIME type based on content.")
}
}
// an empty string is in our case equivalent to not setting the Content-Type
// as `new Blob()` with no `type` option set ends up having type set to an empty string
if (ext_to_mime.has(ext)) {
self.log('service-worker', "+-- guessed MIME type based on extension: " + ext_to_mime.get(ext));
return ext_to_mime.get(ext)
}
// if we're unable to guess the MIME type, we need to return an empty string
self.log('service-worker', " +-- unable to guess the MIME type");
return "";
}
/**
* verifying a config data object
*
* we are *NOT* checking for fields that are unknown/unexpected
* as resilience is more important than conrrectness here:
* we do want the config to load if at all it can be loaded,
* an extra field or two is not a problem here
*
* cdata - config data to verify
*/
let verifyConfigData = (cdata) => {
// cdata needs to be an object
if ( typeof cdata !== "object" || cdata === null ) {
self.log('service-worker', 'fetched config does not contain a valid JSON object')
return false;
}
// basic check for the plugins field
if ( !("plugins" in cdata) || ! Array.isArray(cdata.plugins) ) {
self.log('service-worker', 'fetched config does not contain a valid "plugins" field')
return false;
}
// basic check for the loggedComponents
if ( !("loggedComponents" in cdata) || !Array.isArray(cdata.loggedComponents) ) {
self.log('service-worker', 'fetched config does not contain a valid "loggedComponents" field')
return false;
}
// defaultPluginTimeout is optional
if ("defaultPluginTimeout" in cdata) {
if (!Number.isInteger(cdata.defaultPluginTimeout)) {
self.log('service-worker', 'fetched config contains invalid "defaultPluginTimeout" data (integer expected)')
return false;
}
}
// stillLoadingTimeout is optional
if ("stillLoadingTimeout" in cdata) {
if (!Number.isInteger(cdata.stillLoadingTimeout)) {
self.log('service-worker', 'fetched config contains invalid "stillLoadingTimeout" data (integer expected)')
return false;
}
}
// normalizeQueryParams is optional
if ("normalizeQueryParams" in cdata) {
if (cdata.normalizeQueryParams !== true && cdata.normalizeQueryParams !== false) {
self.log('service-worker', 'fetched config contains invalid "normalizeQueryParams" data (boolean expected)')
return false;
}
}
// useMimeSniffingLibrary is optional
if ("useMimeSniffingLibrary" in cdata) {
if (cdata.useMimeSniffingLibrary !== true && cdata.useMimeSniffingLibrary !== false) {
self.log('service-worker', 'fetched config contains invalid "useMimeSniffingLibrary" data (boolean expected)')
return false;
}
}
// we're good
return true;
}
/**
* cache the `config.json` response, wherever from we got it
*
* configURL - url of the config file
* cresponse - response we're caching
*/
let cacheConfigJSON = async (configURL, cresponse, use_source) => {
try {
var cache = await caches.open(use_source)
await cache.put(configURL, cresponse)
self.log('service-worker', `config cached in cache: ${use_source}.`)
} catch(e) {
self.log('service-worker', `failed to cache config in cache ${use_source}: ${e}`)
}
}
/**
* get config JSON and verify it's valid
*
* cresponse - the Response object to work with
*/
let getConfigJSON = async (cresponse) => {
if ( (cresponse === undefined) || (cresponse === null) || (typeof cresponse !== 'object') || ! ("status" in cresponse) || ! ("statusText" in cresponse) ) {
self.log('service-worker', 'config.json response is undefined or invalid')
return false;
}
if (cresponse.status != 200) {
self.log('service-worker', `config.json response status is not 200: ${cresponse.status} ${cresponse.statusText})`)
return false;
}
// cloning the response before applying json()
// so that we can cache the response later
var cdata = await cresponse.clone().json()
if (verifyConfigData(cdata)) {
return cdata;
}
return false;
}
/**
* execute on the configuration
*
* load plugin modules, making constructors available
* cycle through the plugin config instantiating plugins and their dependencies
*/
let executeConfig = (config) => {
// working on a copy of the plugins config so that config.plugins remains unmodified
// in case we need it later (for example, when re-loading the config)
let pluginsConfig = [...config.plugins]
// this is the stash for plugins that need dependencies instantiated first
let dependentPlugins = new Array()
// do we have any stashing plugins enabled?
// this is important for the still-loading screen
let stashingEnabled = false
// only now load the plugins (config.json could have changed the defaults)
while (pluginsConfig.length > 0) {
// get the first plugin config from the array
let pluginConfig = pluginsConfig.shift()
self.log('service-worker', `handling plugin type: ${pluginConfig.name}`)
// load the relevant plugin script (if not yet loaded)
if (!LibResilientPluginConstructors.has(pluginConfig.name)) {
self.log('service-worker', `${pluginConfig.name}: loading plugin's source`)
self.importScripts(`./plugins/${pluginConfig.name}/index.js`)
}
// do we have any dependencies we should handle first?
if (typeof pluginConfig.uses !== "undefined") {
self.log('service-worker', `${pluginConfig.name}: ${pluginConfig.uses.length} dependencies found`)
// move the dependency plugin configs to LibResilientConfig to be worked on next
for (let i=(pluginConfig.uses.length); i--; i>=0) {
self.log('service-worker', `${pluginConfig.name}: dependency found: ${pluginConfig.uses[i].name}`)
// put the plugin config in front of the plugin configs array
pluginsConfig.unshift(pluginConfig.uses[i])
// set each dependency plugin config to false so that we can keep track
// as we fill those gaps later with instantiated dependency plugins
pluginConfig.uses[i] = false
}
// stash the plugin config until we have all the dependencies handled
self.log('service-worker', `${pluginConfig.name}: not instantiating until dependencies are ready`)
dependentPlugins.push(pluginConfig)
// move on to the next plugin config, which at this point will be
// the first of dependencies for the plugin whose config got stashed
continue;
}
do {
// if the plugin is not enabled, no instantiation for it nor for its dependencies
// if the pluginConfig does not have an "enabled" field, it should be assumed to be "true"
if ( ( "enabled" in pluginConfig ) && ( pluginConfig.enabled != true ) ) {
self.log('service-worker', `skipping ${pluginConfig.name} instantiation: plugin not enabled (dependencies will also not be instantiated)`)
pluginConfig = dependentPlugins.pop()
if (pluginConfig !== undefined) {
let didx = pluginConfig.uses.indexOf(false)
pluginConfig.uses.splice(didx, 1)
}
continue;
}
// instantiate the plugin
let plugin = LibResilientPluginConstructors.get(pluginConfig.name)(self, pluginConfig)
self.log('service-worker', `${pluginConfig.name}: instantiated`)
// is this a stashing plugin?
// we need at least one stashing plugin to be able to use the still-loading screen
stashingEnabled = stashingEnabled || ( ( "stash" in plugin ) && ( typeof plugin.stash === "function" ) )
// do we have a stashed plugin that requires dependencies?
if (dependentPlugins.length === 0) {
// no we don't; so, this plugin goes directly to the plugin list
self.LibResilientPlugins.push(plugin)
// we're done here
self.log('service-worker', `${pluginConfig.name}: no dependent plugins left, pushing directly to LibResilientPlugins`)
break;
}
// at this point clearly there is at least one element in dependentPlugins
// so we can safely assume that the freshly instantiated plugin is a dependency
//
// in that case let's find the first empty spot for a dependency
let didx = dependentPlugins[dependentPlugins.length - 1].uses.indexOf(false)
// assign the freshly instantiated plugin as that dependency
dependentPlugins[dependentPlugins.length - 1].uses[didx] = plugin
self.log('service-worker', `${pluginConfig.name}: assigning as dependency (#${didx}) to ${dependentPlugins[dependentPlugins.length - 1].name}`)
// was this the last one?
if (didx >= dependentPlugins[dependentPlugins.length - 1].uses.length - 1) {
// yup, last one!
self.log('service-worker', `${pluginConfig.name}: this was the last dependency of ${dependentPlugins[dependentPlugins.length - 1].name}`)
// we can now proceed to instantiate the last element of dependentPlugins
pluginConfig = dependentPlugins.pop()
continue
}
// it is not the last one, so there should be more dependency plugins to instantiate first
// before we can instantiate the last of element of dependentPlugins
// but that requires the full treatment, including checing the `uses` field for their configs
self.log('service-worker', `${pluginConfig.name}: not yet the last dependency of ${dependentPlugins[dependentPlugins.length - 1].name}`)
pluginConfig = false
// if pluginConfig is not false, rinse-repeat the plugin instantiation steps
// since we are dealing with the last element of dependentPlugins
} while ( (pluginConfig !== false) && (pluginConfig !== undefined) )
}
// do we want to use MIME type guessing based on content?
// dealing with this at the very end so that we know we can safely set detectMimeFromBuffer
// and not need to re-set it back in case anything fails
if (config.useMimeSniffingLibrary === true) {
// we do not want to hit a NetworkError and end up using the default config
// much better to end up not using the fancy MIME type detection in such a case
try {
// we do! load the external lib
self.importScripts(`./lib/file-type.js`)
} catch (e) {
self.log('service-worker', `error when fetching external MIME sniffing library: ${e.message}`)
}
if (typeof fileType !== 'undefined' && "fileTypeFromBuffer" in fileType) {
detectMimeFromBuffer = fileType.fileTypeFromBuffer
self.log('service-worker', 'loaded external MIME sniffing library')
} else {
self.log('service-worker', 'failed to load external MIME sniffing library!')
}
}
// finally -- if we do not have *any* stashing plugins enabled,
// we need to disable the still-loading screen
if ( ! stashingEnabled ) {
config.stillLoadingTimeout = 0
self.log('service-worker', 'still-loading screen disabled, as there are no stashing plugins enabled')
}
// we're good!
return true;
}
// flag signifying if the SW has been initialized already
var initDone = false
// load the plugins
let initServiceWorker = async () => {
// if init has already been done, skip!
if (initDone) {
self.log('service-worker', 'skipping service-worker init, already done')
return false;
}
// everything in a try-catch block
// so that we get an informative message if there's an error
try {
// we'll need this later
let cresponse = null
let config = null
// self.registration.scope contains the scope this service worker is registered for
// so it makes sense to pull config from `config.json` file directly under that location
// TODO: this should probably be configurable somehow
let configURL = self.registration.scope + "config.json"
// clean version of LibResilientPlugins
// NOTICE: this assumes LibResilientPlugins is not ever used *before* this runs
// NOTICE: this assumption seems to hold currently, but noting for clarity
self.LibResilientPlugins = new Array()
// create the LibResilientPluginConstructors map
// the global... hack is here so that we can run tests; not the most elegant
// TODO: find a better way
self.LibResilientPluginConstructors = self.LibResilientPluginConstructors || new Map()
// point backup of LibResilientPluginConstructors, in case we need to roll back
// this is used during cleanup in executeConfig()
// TODO: handle in a more elegant way
let lrpcBackup = new Map(self.LibResilientPluginConstructors)
// config sources to try
let config_sources = ['v1', 'v1:verified', 'fetch']
// keep track
let config_executed = false
let use_source = false
do {
// init config data var
let cdata = false
// where are we getting the config.json from this time?
// we eitehr get a string (name of a cache, or "fetch" for simple fetch()),
// or undefined (signifying need to use the defaults)
use_source = config_sources.shift()
try {
// are we using any kind of source, or fall back to defaults?
if ( typeof use_source === 'string' ) {
// some kind of source! cache?
if ( ( use_source === 'v1' ) || ( use_source === 'v1:verified' ) ) {
self.log('service-worker', `retrieving config.json from cache: ${use_source}.`)
cresponse = await caches.match(configURL, {cacheName: use_source})
// bail early if we got nothing
if (cresponse === undefined) {
self.log('service-worker', `config.json not found in cache: ${use_source}.`)
continue
}
// regular fetch?
// (we don't have any plugin transports at this point, obviously...)
} else if ( use_source === "fetch" ) {
self.log('service-worker', `retrieving config.json using fetch().`)
cresponse = await fetch(configURL)
// that should not happen!
} else {
throw new Error(`unknown config.json source: ${use_source}; this should never happen!`)
}
// extract the retrieved JSON and verify it
cdata = await getConfigJSON(cresponse)
// do we have anything to work with?
if (cdata === false) {
// cached config.json was invalid; no biggie, try another cache, or fetch()
if ( ( use_source === 'v1' ) || ( use_source === 'v1:verified' ) ) {
self.log('service-worker', `cached config.json is not valid; cache: ${use_source}`)
// if that was a fetch() config, we need to fall-back to defaults!
} else {
self.log('service-worker', `fetched config.json is not valid; using defaults`)
}
// no valid config means we need to go around again
continue
// we good!
} else {
self.log('service-worker', `valid-looking config.json retrieved.`)
}
// anything else just means "use defaults"
} else {
self.log('service-worker', `retrieving config.json failed completely, using built-in defaults.`)
// defaults means an empty object here,
// we're merging with actual defaults later on
cdata = {}
}
// exception? no bueno!
} catch(e) {
self.log('service-worker', `exception when trying to retrieve config.json: ${e.message}`)
continue
}
// merge configs — either with the retrieved JSON,
// or with an empty object if using defaults
config = {...self.LibResilientConfig, ...cdata}
// try executing the config
// we want to catch any and all possible errors here
try {
config_executed = executeConfig(config)
// exception? no bueno
} catch (e) {
// inform
self.log('service-worker', `error while executing config: ${e.message}`)
// cleanup after a failed config execution
self.LibResilientPluginConstructors = new Map(lrpcBackup)
self.LibResilientPlugins = new Array()
// we are not good
config_executed = false;
}
// if we're using the defaults, and yet loading of the config failed
// something is massively wrong
if ( ( use_source === undefined ) && ( config_executed === false ) ) {
// this really should never happen
throw new Error('Failed to load the default config; this should never happen!')
}
// NOTICE: endless loop alert?
// NOTICE: this is not an endless loop because cresponse can only become false if we're using the default config
// NOTICE: and that single case is handled directly above
} while ( ! config_executed )
// we're good
self.LibResilientConfig = config
self.log('service-worker', 'config loaded.')
// we're good, let's cache the config as verified if we need to
// that is, if it comes from the "v1" cache...
if (use_source === "v1") {
self.log('service-worker', `successfully loaded config.json; caching in cache: v1:verified`)
await cacheConfigJSON(configURL, cresponse, 'v1:verified')
// we used the v1:verified cache; we should cache config.json into the v1 cache
// as that will speed things up a bit next time we need to load the service worker
} else if (use_source === "v1:verified") {
self.log('service-worker', `successfully loaded config.json; caching in cache: v1`)
await cacheConfigJSON(configURL, cresponse, 'v1')
// or, was fetch()-ed and valid (no caching if we're going with defaults, obviously)
} else if (use_source === "fetch") {
self.log('service-worker', `successfully loaded config.json; caching in caches: v1, v1:verified`)
// we want to cache to both, so that:
// 1. we get the extra bit of performance from using the v1 cache that is checked first
// 2. but we get the verified config already in the v1:verified cache for later
await cacheConfigJSON(configURL, await cresponse.clone(), 'v1')
await cacheConfigJSON(configURL, cresponse, 'v1:verified')
}
// inform
self.log('service-worker', `service worker initialized.\nstrategy in use: ${self.LibResilientPlugins.map(p=>p.name).join(', ')}`)
initDone = true;
// regardless how we got the config file, if it's older than 24h...
if ( ( cresponse !== false ) && (new Date()) - Date.parse(cresponse.headers.get('date')) > 86400000) {
// try to get it asynchronously through the plugins, and cache it
self.log('service-worker', `config.json stale, fetching through plugins`)
getResourceThroughLibResilient(configURL, {}, 'libresilient-internal', false, false)
.then(async cresponse=>{
// extract JSON and verify it
var cdata = await getConfigJSON(cresponse)
// did that work?
if (cdata === false) {
// we got a false in cdata, that means it probably is invalid (or the fetch failed)
self.log('service-worker', 'config.json loaded through transport other than fetch seems invalid, ignoring')
return false
// otherwise, we good for more in-depth testing!
} else {
// if we got the new config.json via a method *other* than plain old fetch(),
// we will not be able to use importScripts() to load any pugins that have not been loaded already
//
// NOTICE: this *only* checks if we have all the necessary plugin constructors already available
// which signifies that relevant code has been successfully loaded; but there are other failure modes!
if (cresponse.headers.get('x-libresilient-method') != 'fetch') {
// go through the plugins in the new config and check if we already have their constructors
// i.e. if the plugin scripts have already been loaded
let currentPlugin = cdata.plugins.shift()
do {
// plugin constructor not available, meaning: we'd have to importScripts() it
// but we can't since this was not retrieved via fetch(), so we cannot assume
// that the main domain of the website is up and available
//
// if we cache this newly retrieved config.json, next time the service worker gets restarted
// we will end up with an error while trying to run importScripts() for this plugin
// which in turn would lead to the service worker being unregistered
//
// if the main domain is not available, this would mean the website stops working
// even though we *were* able to retrieve the new config.json via plugins!
// so, ignoring this new config.json.
if (!LibResilientPluginConstructors.has(currentPlugin.name)) {
self.log(
'service-worker',
`warning: config.json loaded through transport other than fetch, but specifies not previously loaded plugin: "${currentPlugin.name}"\nignoring the whole config.json.`)
return false;
}
// push any dependencies into the array, at the very front
// thus gradually flattening the config
if ("uses" in currentPlugin) {
cdata.plugins.unshift(...currentPlugin.uses)
}
// get the next plugin to check
currentPlugin = cdata.plugins.shift()
} while ( (currentPlugin !== false) && (currentPlugin !== undefined) )
}
self.log('service-worker', `valid config.json successfully retrieved through plugins; caching.`)
// cache it, asynchronously, in the temporary cache
// as the config has not been "execute-tested" yet
cacheConfigJSON(configURL, cresponse, "v1")
}
})
.catch((e)=>{
self.log('service-worker', `stale config.json fetch failed.`)
})
}
} catch(e) {
// we only get a cryptic "Error while registering a service worker"
// unless we explicitly print the errors out in the console
console.error(e)
// we got an error while initializing the service worker!
// better play it safe!
self.registration.unregister()
return false
}
return true;
}
/**
* fetch counter per clientId
*
* we need to keep track of active fetches per clientId
* so that we can inform a given clientId when we're completely done
*/
self.activeFetches = new Map();
/**
* decrement fetches counter
* and inform the correct clientId if all is finished done
*/
let decrementActiveFetches = (clientId) => {
// decrement the fetch counter for the client
self.activeFetches.set(clientId, self.activeFetches.get(clientId)-1)
self.log('service-worker', '+-- activeFetches[' + clientId + ']:', self.activeFetches.get(clientId))
if (self.activeFetches.get(clientId) === 0) {
self.log('service-worker', 'All fetches done!')
// inform the client
// client has to be smart enough to know if that is just temporary
// (and new fetches will fire in a moment, because a CSS file just
// got fetched) or not
postMessage(clientId, {
allFetched: true
}).then(()=>{
self.log('service-worker', 'all-fetched message queued.')
})
}
}
/*
* returns a Promise that either resolves or rejects after a set timeout
* optionally with a specific error message
*
* time - the timeout (in ms)
* timeout_resolves - whether the Promise should resolve() or reject() when hitting the timeout (default: false (reject))
* error_message - optional error message to use when rejecting (default: false (no error message))
*
* returns an array containing:
* - timeout-related Promise as element 0
* - timeoutID as element 1
*/
let promiseTimeout = (time, timeout_resolves=false, error_message=false) => {
let timeout_id = null
let timeout_promise = new Promise((resolve, reject)=>{
timeout_id = setTimeout(()=>{
if (timeout_resolves) {
resolve(time);
} else {
if (error_message) {
reject(new Error(error_message))
} else {
reject(time)
}
}
}, time);
});
// we need both the promise and the timeout ID
// so that we can clearTimeout() if/when needed
return [timeout_promise, timeout_id]
};
/* ========================================================================= *\
|* === LibResilientClient === *|
\* ========================================================================= */
/**
* Libresilient client class
*
* handles communication with a client
*
* TODO: track active fetches as part of this class?
* TODO: https://gitlab.com/rysiekpl/libresilient/-/issues/83
*/
let LibResilientClient = class {
async postMessage(message) {
// log
self.log('service-worker', 'postMessage():', JSON.stringify(message))
// add our message to the message queue
this.messageQueue.push(message)
// try to get the client from Client API based on clientId
if (! this.client) {
this.client = await self
.clients
.get(this.clientId)
}
// now, we might still not have a valid client here
if (! this.client) {
// store it for later, when we do get a valid client
self.log('service-worker', `postMessage(): no valid client for id: ${this.clientId}, added message to the queue`)
// we have a valid client, it seems!
} else {
// we want all messages to be delivered, and in order they were added
// our message is at the end and will get handled in due course
let msg = false
while (msg = this.messageQueue.shift()) {
try {
this.client.postMessage(msg);
} catch (err) {
// if we fail for whatever reason, bail from the loop
self.log('service-worker', `postMessage(): client seems valid, but postMessage failed; message left in the queue\n- Error message: ${err}`)
this.messageQueue.unshift(msg)
break
}
}
}
}
constructor(clientId) {
self.log('service-worker', `new client: ${clientId}`)
// we often get the clientId long before
// we are able to get a valid client out of it
//
// so we need to keep both
this.clientId = clientId
this.client = null;
// queued messages for when we have a client available
this.messageQueue = []
}
}
// map of all known clients
let LibResilientClients = new Map()
/**
* getting a client based on clientId and sending a message
* (or queueing it for later if we cannot get a valid client)
*/
let postMessage = async (clientId, message) => {
// do we already have a LibResilientClient instance for that client id?
let client = LibResilientClients.get(clientId)
// if not, create it
if (client === undefined) {
client = new LibResilientClient(clientId)
LibResilientClients.set(clientId, client)
}
// send (or queue) the message
await client.postMessage(message)
}
/* ========================================================================= *\
|* === LibResilientResourceInfo === *|
\* ========================================================================= */
/**
* LibResilient resource info class
*
* keeps the values as long as the service worker is running,
* and communicates all changes to relevant clients
*
* clients are responsible for saving and keeping the values across
* service worker restarts, if that's required
*/
let LibResilientResourceInfo = class {
/**
* constructor
* needed to set the URL and clientId
*/
constructor(url, clientId) {
// actual values of the fields
// only used internally, and stored into the Indexed DB
this.values = {
url: '', // read only after initialization
clientId: null, // the client on whose behalf that request is being processed
method: null, // name of the current plugin (in case of state:running) or last plugin (for state:failed or state:success)
state: null, // can be "failed", "success", "running"
serviceWorker: 'COMMIT_UNKNOWN' // this will be replaced by commit sha in CI/CD; read-only
}
// errors from the plugins, contains tuples: [plugin-name, exception-or-response-object]
this.errors = []
// queued messages for when we have a client available
this.messageQueue = []
// set it
this.values.url = url
this.values.clientId = clientId
// we might not have a non-empty clientId if it's a cross-origin fetch
if (clientId) {
postMessage(
clientId,
{...this.values}
)
}
}
/**
* update this.values and immediately postMessage() to the relevant client
*
* data - an object with items to set in this.values
*/
update(data) {
// debug
var msg = 'Updated LibResilientResourceInfo for: ' + this.values.url
// was there a change? if not, no need to postMessage
var changed = false
// update simple read-write properties
Object
.keys(data)
.filter((k)=>{
return ['method', 'state'].includes(k)
})
.forEach((k)=>{
msg += '\n+-- ' + k + ': ' + data[k]
if (this.values[k] !== data[k]) {
msg += ' (changed!)'
changed = true
}
this.values[k] = data[k]
})
// start preparing the data to postMessage() over to the client
let msgdata = {...this.values}
// handle any error related info
if ('error' in data) {
// push the error info, along with method that generated it, onto the error stack
this.errors.push([this.values.method, data.error])
// response?
if ( (typeof data.error === 'object') && ("statusText" in data.error) ) {
msgdata.error = `HTTP status: ${data.error.status} ${data.error.statusText}`
// nope, exception
} else {
msgdata.error = data.error.toString()
}
changed = true
}
self.log('service-worker', msg)
// send the message to the client
if (changed) {
postMessage(
this.values.clientId,
msgdata
);
}
}
/**
* method property
*/
get method() {
return this.values.method
}
/**
* state property
*/
get state() {
return this.values.state
}
/**
* serviceWorker property (read-only)
*/
get serviceWorker() {
return this.values.serviceWorker
}
/**
* url property (read-only)
*/
get url() {
return this.values.url
}
/**
* clientId property (read-only)
*/
get clientId() {
return this.values.clientId
}
}
/* ========================================================================= *\
|* === Main Brain of LibResilient === *|
\* ========================================================================= */