max / quasi
| 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 |
| 34 | const extended = /filename\*=UTF-8''/i.; |
| 35 | |
| 36 | |
| 37 | return ; |
| 38 | |
| 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.; |
| 45 | return quoted ? quoted : null; |
| 46 | }; |
| 47 | |
| 48 | /** Hand the bytes to the browser under this name. */ |
| 49 | const |
| 50 | const url = URL.; |
| 51 | const link = document.; |
| 52 | link. = url; |
| 53 | link. = 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..; |
| 57 | link.; |
| 58 | link.; |
| 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 | ; |
| 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. |
| 69 | const xhr = event.?.; |
| 70 | |
| 71 | return; |
| 72 | |
| 73 | const header = xhr.; |
| 74 | |
| 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.. = false; |
| 82 | event.. = false; |
| 83 | |
| 84 | const type = xhr. || "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 | ; |
| 113 | }; |
| 114 | }); |
| 115 |