// Client-side exception capture. The fleet's Exceptions page is the one
// place a failure is meant to land, and until this existed it only ever
// heard about the half of the app that runs on the server: a broken
// handler on a phone was invisible unless the reader thought to say so.
//
// Deliberately small and defensive. It runs on every page, before any
// other script, and it must never itself become the thing that breaks a
// page: every path is wrapped, and a failure to report is dropped.
(function () {
var ENDPOINT = "/api/client-error";
// The server caps its ring anyway; this is about not making a loop on
// the page into a flood on the wire. One report per signature per
// page-load, and a hard ceiling however many distinct ones there are.
var sent = Object.create(null);
var budget = 8;
function post(kind, message, source, line, col, stack) {
try {
if (budget <= 0) return;
var sig = kind + "|" + message + "|" + source + "|" + line;
if (sent[sig]) return;
sent[sig] = true;
budget--;
var body = JSON.stringify({
kind: kind,
message: String(message || "").slice(0, 500),
source: String(source || "").slice(0, 300),
line: line || 0,
col: col || 0,
stack: String(stack || "").slice(0, 4000),
url: location.pathname + location.search,
ua: navigator.userAgent.slice(0, 200),
});
// sendBeacon survives the page going away, which is exactly when
// a navigation-triggered error would otherwise be lost. fetch is
// the fallback; both are best-effort and neither is awaited.
if (navigator.sendBeacon) {
navigator.sendBeacon(ENDPOINT, new Blob([body], { type: "application/json" }));
return;
}
fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body,
keepalive: true,
}).catch(function () {});
} catch (e) {
/* reporting must never throw */
}
}
// ours reports whether a resource is one THIS SITE shipped.
//
// Every asset quarry references is same-origin by construction — the
// privacy page states it as a promise: the pages load nothing from
// another host. So a cross-origin resource error is never quarry's
// asset failing. It is something else putting a URL on the page: an
// intermediary injecting a script, a browser extension, or a README
// badge pointing at another forge. None of those are "a reader's page
// did not work", and journalling them as server exceptions buries the
// ones that are.
//
// An unparseable or relative-but-odd URL counts as ours, because our
// own URLs always parse and the cost of a false report is one row
// while the cost of a false silence is a broken asset nobody hears
// about.
function ours(url) {
if (!url) return true;
try {
var u = new URL(url, location.href);
// data: and blob: have an opaque origin that matches nothing, but
// only this page's own markup can produce one — an inline
// identicon, an object URL we made. A failure there is ours.
if (u.protocol === "data:" || u.protocol === "blob:") return true;
return u.origin === location.origin;
} catch (e) {
return true;
}
}
window.addEventListener("error", function (ev) {
// A failed <img>/<script>/<link> fires error on the ELEMENT, not the
// window, and carries no message. Those are worth knowing about —
// a stylesheet that 404s is a broken page — but they are a
// different kind, not a thrown exception.
if (ev && ev.target && ev.target !== window && ev.target.tagName) {
var el = ev.target;
var url = el.src || el.href || "";
if (!ours(url)) return;
post("resource", el.tagName.toLowerCase() + " failed to load",
url, 0, 0, "");
return;
}
post("error", (ev && ev.message) || "unknown error",
ev && ev.filename, ev && ev.lineno, ev && ev.colno,
ev && ev.error && ev.error.stack);
}, true); // capture phase: resource errors do not bubble
window.addEventListener("unhandledrejection", function (ev) {
var r = ev && ev.reason;
// A view transition that was skipped rejects its promise with this
// message; Safari surfaces it as unhandled. Nothing failed for the
// reader, so it is not an exception.
var msg = String((r && (r.message || r)) || "");
if (msg.indexOf("skipTransition") >= 0 || msg.indexOf("Skipping view transition") >= 0) { return; }
post("unhandledrejection",
(r && (r.message || r)) || "unhandled promise rejection",
"", 0, 0, r && r.stack);
});
})();
// Back-forward cache. Safari and Chrome restore a page from bfcache
// with the DOM it had when the reader left — a landing that said
// "waiting for checks" keeps saying it, and the streams that would have
// patched it are closed. A restored live page is a stale page, so it
// reloads; a fresh navigation (persisted false) is untouched.
(function () {
try {
window.addEventListener("pageshow", function (ev) {
if (ev && ev.persisted && document.querySelector("[data-init]")) { location.reload(); }
});
} catch (e) {}
})();