Skip to main content

max / quasi

5.5 KB · 115 lines History Blame Raw
1 // What a browser needs before a route's answer can become a saved file.
2 //
3 // `67881a88`. A route answers `Outcome::File` and this host puts the bytes
4 // where downloads go. The header alone is enough for a plain link -- the
5 // browser navigates, sees `Content-Disposition: attachment`, and saves -- but a
6 // control this renderer emits reaches its route through htmx, and htmx reads
7 // the answer into an XHR. An XHR that arrives is not a navigation, so nothing
8 // is saved and the file is swapped into the page as text.
9 //
10 // So this cancels that swap and does the saving itself: a Blob of the body, an
11 // anchor clicked, the object URL revoked. It is the browser's own download
12 // path, reached from script because the request was made from script.
13 //
14 // Nothing here knows what the file is. It reads one thing, the
15 // `Content-Disposition` the http adapter wrote, which is the same header the
16 // no-script path relies on -- so there is one fact on the wire rather than a
17 // second one invented for this file.
18 (() => {
19 "use strict";
20
21 // The header the http adapter writes. RFC 6266 says a lot more than this
22 // reads; the two forms below are the two it writes.
23 const DISPOSITION = "content-disposition";
24
25 /**
26 * The file name the header suggests, or null when it suggests none.
27 *
28 * `filename*` first, because it carries the real characters and the quoted
29 * `filename` beside it is the ASCII fallback for agents that cannot read
30 * one. Both are written for every download, so preferring the richer one is
31 * always available and never a guess.
32 */
33 const named = (header) => {
34 const extended = /filename\*=UTF-8''([^;]+)/i.exec(header);
35 if (extended) {
36 try {
37 return decodeURIComponent(extended[1]);
38 } catch {
39 // A name we cannot decode is a name we do not use. Falling
40 // through to the quoted form is better than saving a file
41 // called `%E2%9C`.
42 }
43 }
44 const quoted = /filename="([^"]*)"/i.exec(header);
45 return quoted ? quoted[1] : null;
46 };
47
48 /** Hand the bytes to the browser under this name. */
49 const save = (blob, name) => {
50 const url = URL.createObjectURL(blob);
51 const link = document.createElement("a");
52 link.href = url;
53 link.download = name;
54 // Firefox will not follow a click on an element outside the document,
55 // which is the one reason this touches the DOM at all.
56 document.body.append(link);
57 link.click();
58 link.remove();
59 // Not revoked synchronously: the click starts the download
60 // asynchronously and revoking first cancels it in some browsers. A task
61 // later is after the download has taken its reference.
62 setTimeout(() => URL.revokeObjectURL(url), 0);
63 };
64
65 // `htmx:beforeSwap` is the last event that can still stop the body reaching
66 // the page, and it carries the XHR, which is where both the header and the
67 // bytes are. Anything later has already destroyed a region.
68 document.addEventListener("htmx:beforeSwap", (event) => {
69 const xhr = event.detail?.xhr;
70 if (!xhr) {
71 return;
72 }
73 const header = xhr.getResponseHeader(DISPOSITION);
74 if (!header || !/^\s*attachment/i.test(header)) {
75 return;
76 }
77
78 // Nothing goes into the document. The screen the user pressed the
79 // control on is the screen they keep, which is what the router said by
80 // answering a file rather than a fragment.
81 event.detail.shouldSwap = false;
82 event.detail.isError = false;
83
84 const type = xhr.getResponseHeader("content-type") || "application/octet-stream";
85 // THIS IS TEXT-ONLY, AND NOTHING BINARY REACHES IT.
86 //
87 // htmx leaves `responseType` unset, so the browser has already decoded
88 // the body as UTF-8 by the time this runs and `xhr.response` is a
89 // string. That round-trips losslessly for text -- JSON, CSV, ICS, every
90 // export in the tree -- and mangles any byte sequence that is not valid
91 // UTF-8, because the replacement characters were substituted before
92 // this file could see them.
93 //
94 // The limit is enforced on the other side rather than described here.
95 // `3bdf1a75`: `quasi-http` knows the `Accepted` and knows from
96 // `HX-Request` that the caller is an XHR, so it refuses a file it
97 // cannot show to be text with a 501 naming both ways out. A corrupt
98 // download that looks successful is the failure worth closing, and the
99 // guard is what closes it -- this file can assume its bytes survived.
100 //
101 // The fixes that would lift the limit all cost more than the case is
102 // worth today. Setting `responseType = "blob"` up front breaks every
103 // ordinary swap, since htmx reads the same field to get its markup.
104 // `overrideMimeType` with `x-user-defined` does the same damage to
105 // every UTF-8 page. Refetching the URL as a blob is correct and runs
106 // the route a second time, which is wrong for a POST and wrong for
107 // anything with a side effect.
108 //
109 // Nothing here is wrong for a plain link, which never reaches this file
110 // at all: the browser navigates, reads the same header, and saves the
111 // bytes as they arrived. That is also the way out the guard names.
112 save(new Blob([xhr.response], { type }), named(header) || "download");
113 });
114 })();
115