aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js200
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js37
3 files changed, 239 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 6ac5cdb..3e23961 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -180,6 +180,10 @@ _HTML = """\
and app.js's own relative imports inherit the prefix, which is the only
way the module graph is guaranteed not to be a mixture of two builds.
See _asset_version() and VersionedStatics. -->
+ <!-- First, and not a module: if the graph below never links, no module code
+ runs at all, and this is what keeps the page from being silently blank.
+ It draws nothing unless #app is still empty ten seconds from now. -->
+ <script src="/a/{v}/boot-guard.js"></script>
<!-- Argon2id (WebAssembly, inlined) — WebCrypto has no memory-hard KDF, and the
keypair bundle needs one: it is protected by the passphrase alone and sits
on every node its owner joins (C4). Vendored, see static/vendor/PROVENANCE.md -->
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js b/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js
new file mode 100644
index 0000000..f5e3aa1
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js
@@ -0,0 +1,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);
+}());
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index dbd5037..eead457 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -18,6 +18,7 @@ const IDB_NAME = 'meshbay';
// disagreeing about the version is a `VersionError` thrown at whichever of
// them happens to run second.
const IDB_VERSION = 2;
+const OPEN_DB_TIMEOUT_MS = 5000;
const IDB_STORE = 'group_indexes';
const IDB_PLAYLISTS = 'playlists';
@@ -27,9 +28,32 @@ function navigate(path) {
// ── IndexedDB cache ─────────────────────────────────────────────────────────
+/**
+ * The database, opened — or refused, but never left hanging.
+ *
+ * A version upgrade waits for every other connection to this database to
+ * close. A second tab of this site holding version 1 open is enough to stop
+ * it, and `indexedDB.open` then fires **neither** `success` nor `error`: it
+ * fires `blocked`, and if nothing handles that the promise never settles. Every
+ * `await openDB()` behind it waits for ever, which reads as a feature that
+ * silently does nothing rather than as a failure anybody can see.
+ *
+ * So `blocked` is heard, and a deadline covers the rest. Callers already treat
+ * a rejection as "no local cache this time" and carry on.
+ */
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB_NAME, IDB_VERSION);
+ let settled = false;
+ const done = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
+ // Generous: the other tab is asked to close and usually does within a
+ // frame. This is the backstop for the one that cannot — a page suspended
+ // on a phone, say — not a latency budget.
+ const deadline = setTimeout(() => done(reject, new Error(
+ 'IndexedDB open timed out (another tab may hold an older version open)')),
+ OPEN_DB_TIMEOUT_MS);
+ req.onblocked = () => done(reject, new Error(
+ 'IndexedDB upgrade blocked by another tab of this site'));
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
@@ -43,8 +67,17 @@ function openDB() {
db.createObjectStore(IDB_PLAYLISTS);
}
};
- req.onsuccess = () => resolve(req.result);
- req.onerror = () => reject(req.error);
+ req.onsuccess = () => {
+ clearTimeout(deadline);
+ // Giving up does not cancel the request: the other tab eventually closes,
+ // the upgrade goes through, and this fires with a live connection nobody
+ // is waiting for. Left open it squats the database — blocking the next
+ // upgrade *and* any attempt to delete it, which is the failure this
+ // whole guard exists to end, arriving through the back door.
+ if (settled) { try { req.result.close(); } catch { /* already gone */ } return; }
+ done(resolve, req.result);
+ };
+ req.onerror = () => { clearTimeout(deadline); done(reject, req.error); };
});
}