summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js
blob: f5e3aa1193586de3914881987b562bae97875854 (plain) (blame)
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
/**
 * The page can never stay silently blank.
 *
 * A reader once had nothing but white for an evening: the shell arrived, the
 * modules were served from the browser's own store so not one request reached
 * the hub, and nothing ever rendered. No message, no error on screen, nothing
 * in the server's log to look at — the same account worked in a private window
 * and in another browser, which is the signature of something wrong in this
 * origin's stored state rather than in what was deployed. Clearing the site's
 * data fixed it. Clearing the *cache*, three times, had not: a service worker
 * and IndexedDB are not the cache, and nothing on screen said so.
 *
 * This answers the *silence*, not whatever caused it. A blank page is a bug
 * report nobody can write, and a phone has no console to open.
 *
 * **A classic script, deliberately, and first.** The failure it guards against
 * includes the module graph never linking — one bad module and no module code
 * runs at all, so a guard inside `app.js` would be part of what failed. This
 * one has no imports and cannot be stopped by them.
 *
 * It does nothing when the application mounts, which is the ordinary case: it
 * looks once, late, and speaks only to a reader already staring at nothing.
 */
(function () {
  'use strict';

  // Long enough that a cold phone on a slow network is never interrupted —
  // argon2's wasm, forty modules and a catalogue — and short enough that
  // nobody sits in front of white wondering. The app mounts in well under two
  // seconds when it mounts at all.
  var GIVE_UP_MS = 10000;
  var problems = [];

  function note(what) {
    if (problems.length < 5) problems.push(String(what).slice(0, 200));
  }

  // Capture phase: a `<script>` or stylesheet that fails to load fires `error`
  // at the element, and that one does not bubble.
  window.addEventListener('error', function (e) {
    if (e && e.target && e.target !== window && e.target.src) {
      note('could not load ' + String(e.target.src).replace(/^https?:\/\/[^/]+/, ''));
    } else if (e && e.message) {
      note(e.message);
    }
  }, true);

  window.addEventListener('unhandledrejection', function (e) {
    var r = e && e.reason;
    note((r && (r.message || r)) || 'a promise rejected');
  });

  var FR = (navigator.language || '').toLowerCase().indexOf('fr') === 0;
  var TEXT = FR ? {
    title: 'MeshBay n’a pas pu démarrer',
    body: 'L’application s’est arrêtée avant d’afficher quoi que ce soit. '
        + 'Cela vient presque toujours des données que ce navigateur garde pour ce site.',
    retry: 'Réessayer',
    reset: 'Réinitialiser les données de ce site',
    warn: 'Vous devrez vous reconnecter. Vos fichiers et vos groupes ne sont pas '
        + 'touchés : ils vivent sur les nœuds, pas ici.',
    doing: 'Réinitialisation…',
    blocked: 'Un autre onglet de ce site garde la base ouverte. Fermez les autres '
           + 'onglets meshbay, puis réessayez.',
  } : {
    title: 'MeshBay could not start',
    body: 'The application stopped before it drew anything. This is almost always '
        + 'the data this browser keeps for this site.',
    retry: 'Try again',
    reset: 'Reset this site’s data',
    warn: 'You will have to sign in again. Your files and groups are untouched: '
        + 'they live on the nodes, not here.',
    doing: 'Resetting…',
    blocked: 'Another tab of this site is holding the database open. Close the '
           + 'other meshbay tabs, then try again.',
  };

  /**
   * Deleting a database waits for every connection to it to close, and another
   * tab of this site is a connection. Unwatched, `deleteDatabase` then does
   * nothing at all and says nothing — so this page would reload into the state
   * it had just promised to clear, which is the bug this file exists to end.
   * Blocked deletions are named and handed back.
   */
  function dropDatabase(name) {
    return new Promise(function (res) {
      var req;
      try { req = indexedDB.deleteDatabase(name); } catch (e) { return res(null); }
      var settled = false;
      var end = function (v) { if (!settled) { settled = true; res(v); } };
      req.onsuccess = function () { end(null); };
      req.onerror = function () { end(null); };
      req.onblocked = function () { end(name); };
      setTimeout(function () { end(name); }, 2500);
      return undefined;
    });
  }

  /** Everything this origin holds, and the service worker with it. */
  function resetSiteData(done) {
    var waiting = [];
    var blocked = [];
    try { localStorage.clear(); } catch (e) { /* private mode */ }
    try { sessionStorage.clear(); } catch (e) { /* private mode */ }

    if (window.indexedDB) {
      var names = indexedDB.databases
        ? indexedDB.databases().then(function (dbs) {
            return (dbs || []).map(function (d) { return d && d.name; }).filter(Boolean);
          }).catch(function () { return ['meshbay', 'meshbay_keys']; })
        // Safari and older engines have no `databases()`; these are ours.
        : Promise.resolve(['meshbay', 'meshbay_keys']);
      waiting.push(names.then(function (list) {
        return Promise.all(list.map(dropDatabase)).then(function (results) {
          results.forEach(function (n) { if (n) blocked.push(n); });
        });
      }).catch(function () {}));
    }

    if (window.caches && caches.keys) {
      waiting.push(caches.keys().then(function (keys) {
        return Promise.all((keys || []).map(function (k) { return caches.delete(k); }));
      }).catch(function () {}));
    }

    if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) {
      waiting.push(navigator.serviceWorker.getRegistrations().then(function (regs) {
        return Promise.all((regs || []).map(function (r) { return r.unregister(); }));
      }).catch(function () {}));
    }

    // Bounded: a hung unregister must not leave the reader on "Resetting…" for
    // ever, which would be this bug wearing a different hat.
    var fired = false;
    var finish = function () { if (!fired) { fired = true; done(blocked); } };
    Promise.all(waiting).then(finish).catch(finish);
    setTimeout(finish, 4000);
  }

  function el(tag, style, text) {
    var n = document.createElement(tag);
    if (style) n.setAttribute('style', style);
    if (text) n.textContent = text;
    return n;
  }

  function show(root) {
    var BTN = 'display:block;width:100%;margin:8px 0;padding:12px 16px;font:inherit;'
            + 'font-size:15px;border-radius:8px;border:1px solid #c9ccd1;background:#fff;'
            + 'color:#111;cursor:pointer';
    var box = el('div', 'max-width:34em;margin:12vh auto;padding:0 20px;'
      + 'font:15px/1.55 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#111');

    box.appendChild(el('h1', 'font-size:20px;margin:0 0 12px', TEXT.title));
    box.appendChild(el('p', 'margin:0 0 16px;color:#444', TEXT.body));

    var retry = el('button', BTN, TEXT.retry);
    retry.addEventListener('click', function () { location.reload(); });
    box.appendChild(retry);

    var reset = el('button', BTN + ';border-color:#b23;color:#b23', TEXT.reset);
    reset.addEventListener('click', function () {
      reset.disabled = true;
      reset.textContent = TEXT.doing;
      resetSiteData(function (blocked) {
        if (!blocked.length) { location.reload(); return; }
        // Reloading here would be a lie: the database is still there, held by
        // another tab, and the page would come back exactly as broken.
        reset.disabled = false;
        reset.textContent = TEXT.reset;
        var why = el('p', 'margin:12px 0 0;color:#b23;font-size:13px',
          TEXT.blocked + ' (' + blocked.join(', ') + ')');
        why.setAttribute('data-blocked', blocked.join(','));
        box.appendChild(why);
      });
    });
    box.appendChild(reset);

    box.appendChild(el('p', 'margin:12px 0 0;color:#666;font-size:13px', TEXT.warn));

    if (problems.length) {
      // Verbatim, and on screen. Nothing else reaches a reader who cannot open
      // a console, and it is the first thing anybody diagnosing this will ask.
      var pre = el('pre', 'margin:16px 0 0;padding:10px;background:#f3f4f6;'
        + 'border-radius:6px;font-size:12px;white-space:pre-wrap;word-break:break-word;'
        + 'color:#333', problems.join('\n'));
      box.appendChild(pre);
    }

    root.appendChild(box);
  }

  setTimeout(function () {
    var root = document.getElementById('app');
    // Mounted, which is the ordinary case and the end of this script's
    // involvement. A later render replaces whatever is here anyway.
    if (!root || root.firstChild) return;
    show(root);
  }, GIVE_UP_MS);
}());