cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
root / app / static / fasttree.js

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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&#34;', "'": '&#39;' };
     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 += ' &amp; ';
    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);