// What a browser needs before a route's answer can become a saved file. // // `67881a88`. A route answers `Outcome::File` and this host puts the bytes // where downloads go. The header alone is enough for a plain link -- the // browser navigates, sees `Content-Disposition: attachment`, and saves -- but a // control this renderer emits reaches its route through htmx, and htmx reads // the answer into an XHR. An XHR that arrives is not a navigation, so nothing // is saved and the file is swapped into the page as text. // // So this cancels that swap and does the saving itself: a Blob of the body, an // anchor clicked, the object URL revoked. It is the browser's own download // path, reached from script because the request was made from script. // // Nothing here knows what the file is. It reads one thing, the // `Content-Disposition` the http adapter wrote, which is the same header the // no-script path relies on -- so there is one fact on the wire rather than a // second one invented for this file. (() => { "use strict"; // The header the http adapter writes. RFC 6266 says a lot more than this // reads; the two forms below are the two it writes. const DISPOSITION = "content-disposition"; /** * The file name the header suggests, or null when it suggests none. * * `filename*` first, because it carries the real characters and the quoted * `filename` beside it is the ASCII fallback for agents that cannot read * one. Both are written for every download, so preferring the richer one is * always available and never a guess. */ const named = (header) => { const extended = /filename\*=UTF-8''([^;]+)/i.exec(header); if (extended) { try { return decodeURIComponent(extended[1]); } catch { // A name we cannot decode is a name we do not use. Falling // through to the quoted form is better than saving a file // called `%E2%9C`. } } const quoted = /filename="([^"]*)"/i.exec(header); return quoted ? quoted[1] : null; }; /** Hand the bytes to the browser under this name. */ const save = (blob, name) => { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = name; // Firefox will not follow a click on an element outside the document, // which is the one reason this touches the DOM at all. document.body.append(link); link.click(); link.remove(); // Not revoked synchronously: the click starts the download // asynchronously and revoking first cancels it in some browsers. A task // later is after the download has taken its reference. setTimeout(() => URL.revokeObjectURL(url), 0); }; // `htmx:beforeSwap` is the last event that can still stop the body reaching // the page, and it carries the XHR, which is where both the header and the // bytes are. Anything later has already destroyed a region. document.addEventListener("htmx:beforeSwap", (event) => { const xhr = event.detail?.xhr; if (!xhr) { return; } const header = xhr.getResponseHeader(DISPOSITION); if (!header || !/^\s*attachment/i.test(header)) { return; } // Nothing goes into the document. The screen the user pressed the // control on is the screen they keep, which is what the router said by // answering a file rather than a fragment. event.detail.shouldSwap = false; event.detail.isError = false; const type = xhr.getResponseHeader("content-type") || "application/octet-stream"; // THIS IS TEXT-ONLY, AND NOTHING BINARY REACHES IT. // // htmx leaves `responseType` unset, so the browser has already decoded // the body as UTF-8 by the time this runs and `xhr.response` is a // string. That round-trips losslessly for text -- JSON, CSV, ICS, every // export in the tree -- and mangles any byte sequence that is not valid // UTF-8, because the replacement characters were substituted before // this file could see them. // // The limit is enforced on the other side rather than described here. // `3bdf1a75`: `quasi-http` knows the `Accepted` and knows from // `HX-Request` that the caller is an XHR, so it refuses a file it // cannot show to be text with a 501 naming both ways out. A corrupt // download that looks successful is the failure worth closing, and the // guard is what closes it -- this file can assume its bytes survived. // // The fixes that would lift the limit all cost more than the case is // worth today. Setting `responseType = "blob"` up front breaks every // ordinary swap, since htmx reads the same field to get its markup. // `overrideMimeType` with `x-user-defined` does the same damage to // every UTF-8 page. Refetching the URL as a blob is correct and runs // the route a second time, which is wrong for a POST and wrong for // anything with a side effect. // // Nothing here is wrong for a plain link, which never reaches this file // at all: the browser navigates, reads the same header, and saves the // bytes as they arrived. That is also the way out the guard names. save(new Blob([xhr.response], { type }), named(header) || "download"); }); })();