fasttree.js (14504B)
1 /*! 2 * cdnV2 instant folder navigation. 3 * 4 * The browser keeps the whole file tree (/api/tree) and a page "shell" (/api/shell) in localStorage. 5 * Clicking a link to a folder then builds that folder's page locally and hands it to Turbo, so there is 6 * no request at all: it works on the very first click after a page loads, even before any prefetch, 7 * which is what people who know where they're going do. 8 * 9 * All HTML comes from the server's own templates (templates/_listing.html); this file only fills in the 10 * data. tests/test_fast_tree.py checks that a locally built page equals the server-rendered one. 11 * 12 * Safe by construction: anything unusual (no tree yet, unknown folder, modified click, query/hash in the 13 * URL, any error) falls through to the normal request, so the worst case is today's behaviour. The tree 14 * is revalidated in the background (ETag, so an unchanged tree is a tiny 304); if it turns out the page 15 * you are on was built from an older tree, it is quietly rebuilt. Trees above FAST_TREE_MAX_FILES on the 16 * server are not shipped and the feature switches itself off. 17 * 18 * Opt out per page: <meta name="cdn-fast" content="off">. 19 */ 20 (function (global) { 21 'use strict'; 22 23 const STORE_KEY = 'cdn_fast_v1'; 24 const MIN_REFRESH_MS = 15000; 25 const HIDDEN_ROW = '.gitattributes'; // rows the listing template skips 26 // First path segments that belong to the app, never to the CDN's content (same list as prefetch.js). 27 const RESERVED = new Set(['api', 'admin', 'mask', 'pack', 'static', 'raw']); 28 29 // --- text helpers: byte-for-byte what Jinja produces --------------------------------------------- 30 31 const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; 32 // markupsafe.escape 33 function escapeHtml(v) { 34 return String(v).replace(/[&<>"']/g, (c) => ESC[c]); 35 } 36 37 // Jinja's |tojson: ASCII-only JSON, with <, >, & and ' as \u escapes (safe inside <script>). 38 function jsonForScript(v) { 39 return JSON.stringify(v) 40 .replace(/[^\x20-\x7e]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')) 41 .replace(/</g, '\\u003c') 42 .replace(/>/g, '\\u003e') 43 .replace(/&/g, '\\u0026') 44 .replace(/'/g, '\\u0027'); 45 } 46 47 // storage.format_size. Python rounds half to even on the exact value, JavaScript's toFixed rounds 48 // half up, and 1.125 KB is exactly representable, so do the arithmetic exactly with BigInt. 49 function formatSize(bytes) { 50 const units = ['B', 'KB', 'MB', 'GB', 'TB']; 51 const n = BigInt(Math.trunc(bytes)); 52 let idx = 0; 53 let d = 1n; 54 while (n >= d * 1024n && idx < units.length - 1) { 55 d *= 1024n; 56 idx++; 57 } 58 if (idx === 0) return `${n} ${units[0]}`; 59 const scaled = n * 100n; 60 let q = scaled / d; 61 const r2 = (scaled % d) * 2n; 62 if (r2 > d || (r2 === d && q % 2n === 1n)) q += 1n; 63 return `${q / 100n}.${(q % 100n).toString().padStart(2, '0')} ${units[idx]}`; 64 } 65 66 // --- the tree -------------------------------------------------------------------------------------- 67 68 // files: [[path, size], ...] -> folder index 69 function buildIndex(files) { 70 const folders = new Map(); // folder path -> { dirs: Set(name), files: [[name, size]] } 71 const get = (p) => { 72 let f = folders.get(p); 73 if (!f) folders.set(p, (f = { dirs: new Set(), files: [] })); 74 return f; 75 }; 76 get(''); 77 let bytes = 0; 78 for (const [path, size] of files) { 79 const segs = path.split('/'); 80 const name = segs.pop(); 81 let parent = ''; 82 for (const seg of segs) { 83 get(parent).dirs.add(seg); 84 parent = parent ? parent + '/' + seg : seg; 85 get(parent); 86 } 87 get(parent).files.push([name, size]); 88 bytes += size; 89 } 90 return { folders, count: files.length, bytes }; 91 } 92 93 // Same items, same order as storage.list_directory: folders first, then by lower-cased name. 94 function listFolder(index, path) { 95 const f = index.folders.get(path); 96 if (!f) return null; 97 const prefix = path ? path + '/' : ''; 98 const cmp = (a, b) => { 99 const x = a.name.toLowerCase(); 100 const y = b.name.toLowerCase(); 101 return x < y ? -1 : x > y ? 1 : 0; 102 }; 103 const dirs = [...f.dirs].map((n) => ({ name: n, path: prefix + n, is_dir: true, size_str: '-' })); 104 const files = f.files.map(([n, s]) => ({ name: n, path: prefix + n, is_dir: false, size_str: formatSize(s) })); 105 return dirs.sort(cmp).concat(files.sort(cmp)); 106 } 107 108 // --- building a page ------------------------------------------------------------------------------- 109 110 // Replace @@key@@ in one pass, so a value that happens to contain "@@x@@" is never expanded again. 111 function fill(tpl, map) { 112 return tpl.replace(/@@([a-z_]+)@@/g, (m, k) => (Object.prototype.hasOwnProperty.call(map, k) ? map[k] : m)); 113 } 114 115 function breadcrumb(path, parts) { 116 const crumbs = [{ name: 'root', href: '/' }]; 117 let cur = ''; 118 for (const seg of path.split('/')) { 119 cur += seg + '/'; 120 crumbs.push({ name: seg, href: '/' + cur.slice(0, -1) }); 121 } 122 const link = (c) => fill(parts.crumb_link, { href: escapeHtml(c.href), name: escapeHtml(c.name) }); 123 const last = crumbs.length - 1; 124 let inner; 125 if (crumbs.length > 4) { 126 // root, "...", then the last two; the folders in between go into the "..." menu 127 const menu = ' ' + crumbs.slice(1, -2).map(link).join(' ') + ' '; 128 inner = [link(crumbs[0]), fill(parts.crumb_collapsed, { menu }), '/ ' + link(crumbs[last - 1]), '/ ' + link(crumbs[last])].join(' '); 129 } else { 130 inner = crumbs.map((c, i) => (i === 0 ? link(c) : '/ ' + link(c))).join(' '); 131 } 132 return fill(parts.crumb_container, { inner: ' ' + inner + ' ', tail: fill(parts.copy_btn, { path: escapeHtml(path) }) }); 133 } 134 135 const SLOT_RE = /<!--@(?:root-header|breadcrumb|toolbar|list|stats)-->|\/\*@visit\*\//g; 136 137 // The complete HTML of the page for folder `path` ('' is the root), or null if the tree has no such folder. 138 function compose(shell, index, path) { 139 const items = listFolder(index, path); 140 if (!items) return null; 141 const p = shell.parts; 142 const visible = items.filter((i) => i.name !== HIDDEN_ROW); 143 const dirs = visible.filter((i) => i.is_dir).length; 144 const files = visible.length - dirs; 145 const segs = path ? path.split('/') : []; 146 const parent = segs.length > 1 ? '/' + segs.slice(0, -1).join('/') : '/'; 147 148 const count = (n, word) => fill(p.count, { n: String(n), word, s: n === 1 ? '' : 's' }); 149 let counts = ''; 150 if (dirs > 0) counts += count(dirs, 'folder'); 151 if (dirs > 0 && files > 0) counts += ' & '; 152 if (files > 0) counts += count(files, 'file'); 153 if (dirs === 0 && files === 0) counts += '0 items'; 154 155 const rows = visible.map((i) => 156 i.is_dir 157 ? fill(p.row_dir, { name: escapeHtml(i.name), path: escapeHtml(i.path) }) 158 : fill(p.row_file, { name: escapeHtml(i.name), path: escapeHtml(i.path), size_str: escapeHtml(i.size_str) }) 159 ); 160 let list = path ? fill(p.go_up, { parent_path: escapeHtml(parent) }) + '\n' : ''; 161 list += rows.join('\n'); 162 if (visible.length === 0) list += '\n' + p.empty; 163 164 const values = { 165 '<!--@root-header-->': path ? '' : p.root_header, 166 '<!--@breadcrumb-->': path ? breadcrumb(path, p) : '', 167 '<!--@toolbar-->': items.length ? fill(p.toolbar, { counts: ' ' + counts + ' ', path: escapeHtml(path) }) : '', 168 '<!--@list-->': list, 169 '<!--@stats-->': fill(p.stats, { n: String(index.count), s: index.count === 1 ? '' : 's', size_str: formatSize(index.bytes) }), 170 '/*@visit*/': path 171 ? fill(p.visit, { path_json: jsonForScript(path), cdn_url_json: jsonForScript(shell.cdn_base + '/' + path + '/') }) 172 : '', 173 }; 174 return shell.html.replace(SLOT_RE, (m) => values[m]); 175 } 176 177 // '/a%26b/x/' -> 'a&b/x'; null when the URL is not a plain content path. 178 function folderPathFromUrl(url) { 179 if (url.search || url.hash) return null; 180 let p; 181 try { 182 p = decodeURIComponent(url.pathname); 183 } catch (e) { 184 return null; 185 } 186 p = p.replace(/^\/+|\/+$/g, ''); 187 if (RESERVED.has(p.split('/')[0])) return null; 188 return p; 189 } 190 191 // --- in the browser -------------------------------------------------------------------------------- 192 193 function boot(win) { 194 const doc = win.document; 195 if (!doc || win.__cdnFast || typeof win.fetch !== 'function') return; 196 197 const state = { tree: null, shell: null, lastCheck: 0, disabled: false, pending: null, local: null }; 198 199 function install(tree, shell) { 200 state.tree = { v: tree.v, files: tree.files, index: buildIndex(tree.files) }; 201 state.shell = shell; 202 } 203 function persist() { 204 try { 205 win.localStorage.setItem( 206 STORE_KEY, 207 JSON.stringify({ tree: { v: state.tree.v, files: state.tree.files }, shell: state.shell }) 208 ); 209 } catch (e) { /* storage full or blocked: still works for this session */ } 210 } 211 212 // Warm start: what we saved on the last visit is usable immediately, before any network. 213 try { 214 const saved = JSON.parse(win.localStorage.getItem(STORE_KEY) || 'null'); 215 if (saved && saved.tree && saved.tree.files && saved.shell && saved.shell.parts) install(saved.tree, saved.shell); 216 } catch (e) { /* corrupt entry: refetched below */ } 217 218 const turboReady = () => !!(win.Turbo && typeof win.Turbo.visit === 'function'); 219 const optedOut = () => !!doc.querySelector('meta[name="cdn-fast"][content="off"]'); 220 const ready = () => !state.disabled && !!state.tree && !!state.shell && turboReady() && !optedOut(); 221 222 function pathFor(url) { 223 if (!ready() || url.origin !== win.location.origin) return null; 224 const path = folderPathFromUrl(url); 225 return path !== null && state.tree.index.folders.has(path) ? path : null; 226 } 227 228 function visit(url, path, action) { 229 const html = compose(state.shell, state.tree.index, path); 230 if (html === null) return false; 231 state.pending = { path, treeV: state.tree.v }; 232 win.Turbo.visit(url.pathname, { action, response: { statusCode: 200, responseHTML: html } }); 233 return true; 234 } 235 236 function onClick(e) { 237 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; 238 const a = e.target && e.target.closest && e.target.closest('a[href]'); 239 if (!a || !ready()) return; 240 if (a.hasAttribute('download') || (a.target && a.target !== '_self') || a.closest('[data-turbo="false"]')) return; 241 try { 242 const url = new URL(a.href, win.location.href); 243 const path = pathFor(url); 244 if (path === null) return; 245 // Turbo ignores a click that has been default-prevented, so it doesn't also fetch the page. 246 if (visit(url, path, 'advance')) e.preventDefault(); 247 } catch (err) { 248 state.pending = null; // anything odd: let the normal navigation happen 249 } 250 } 251 doc.addEventListener('click', onClick, true); 252 253 function getJson(url) { 254 return win.fetch(url, { credentials: 'same-origin', priority: 'low' }).then((r) => { 255 if (!r.ok) throw new Error(url + ' ' + r.status); 256 return r.json(); 257 }); 258 } 259 260 // If the page on screen was built from an older tree than the one we now have, rebuild it. 261 function reconcile() { 262 const here = state.local; 263 if (!here || !state.tree || here.treeV === state.tree.v) return; 264 let url; 265 try { 266 url = new URL(win.location.href); 267 } catch (e) { 268 return; 269 } 270 if (folderPathFromUrl(url) !== here.path) return; 271 if (!visit(url, here.path, 'replace')) win.Turbo.visit(url.pathname, { action: 'replace' }); // folder is gone: ask the server 272 } 273 274 let inflight = null; 275 let again = false; 276 function refresh(force) { 277 const now = Date.now(); 278 if (!force && now - state.lastCheck < MIN_REFRESH_MS) return Promise.resolve(); 279 // One request at a time. A forced refresh that arrives mid-flight (e.g. several files finishing 280 // uploading together) runs once more afterwards, because the flight may have started too early. 281 if (inflight) { 282 if (force) again = true; 283 return inflight; 284 } 285 state.lastCheck = now; 286 inflight = Promise.all([getJson('/api/tree'), getJson('/api/shell')]) 287 .then(([tree, shell]) => { 288 if (tree.too_large) { 289 state.disabled = true; 290 try { win.localStorage.removeItem(STORE_KEY); } catch (e) { /* ignore */ } 291 return; 292 } 293 if (state.tree && state.shell && state.tree.v === tree.v && state.shell.v === shell.v) return; 294 install(tree, shell); 295 persist(); 296 if (turboReady()) reconcile(); 297 }) 298 .catch(() => { /* offline, or the server is busy: keep what we have */ }) 299 .then(() => { 300 inflight = null; 301 if (again) { 302 again = false; 303 return refresh(true); 304 } 305 }); 306 return inflight; 307 } 308 309 // Keep the tree fresh without ever blocking anything. 310 const idle = (fn) => (win.requestIdleCallback ? win.requestIdleCallback(fn, { timeout: 2000 }) : win.setTimeout(fn, 300)); 311 const start = () => idle(() => refresh(false)); 312 if (doc.readyState !== 'loading') start(); 313 else doc.addEventListener('DOMContentLoaded', start, { once: true }); 314 315 doc.addEventListener('turbo:before-visit', () => { if (!state.pending) state.local = null; }); 316 doc.addEventListener('turbo:load', () => { 317 if (state.pending) { 318 state.local = state.pending; 319 state.pending = null; 320 } 321 idle(() => refresh(false)); 322 }); 323 doc.addEventListener('visibilitychange', () => { if (!doc.hidden) refresh(false); }); 324 325 win.__cdnFast = { 326 refresh, // refresh(true) forces a check, e.g. right after an upload 327 handles: (url) => { 328 try { 329 return pathFor(new URL(url, win.location.href)) !== null; 330 } catch (e) { 331 return false; 332 } 333 }, 334 stats: () => ({ ready: ready(), files: state.tree ? state.tree.index.count : 0, treeVersion: state.tree && state.tree.v, shellVersion: state.shell && state.shell.v }), 335 }; 336 } 337 338 if (typeof module === 'object' && module.exports) { 339 module.exports = { escapeHtml, jsonForScript, formatSize, buildIndex, listFolder, compose, folderPathFromUrl, boot }; 340 } else { 341 boot(global); 342 } 343 })(typeof window !== 'undefined' ? window : globalThis);