Created
October 6, 2025 08:03
-
-
Save u1-liquid/1b65ab1f9af527fc26b1a6576919fd5c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Bindings required: | |
| // - R2: R2 bucket | |
| // - KV: KV namespace | |
| // - MONITOR_URL: e.g. "https://example.com/test.json" | |
| const MONITOR_URL = 'https://example.com/test.json' | |
| export default { | |
| async scheduled(event, env, ctx) { | |
| ctx.waitUntil(run(env)); | |
| }, | |
| }; | |
| async function run(env) { | |
| // Fetch the JSON without cache | |
| const res = await fetch(MONITOR_URL, { cf: { cacheTtl: 0, cacheEverything: false } }); | |
| if (!res.ok) throw new Error(`monitor fetch failed: ${res.status}`); | |
| const data = await res.json(); | |
| const nextUrl = data?.url; | |
| if (!nextUrl || typeof nextUrl !== "string") { | |
| throw new Error("url missing in JSON"); | |
| } | |
| const prevUrl = await env.KV.get("url"); | |
| if (prevUrl && prevUrl === nextUrl) { | |
| // No change detected | |
| return; | |
| } | |
| // Download the artifact | |
| const fileRes = await fetch(nextUrl, { cf: { cacheTtl: 0, cacheEverything: false } }); | |
| if (!fileRes.ok) throw new Error(`download failed: ${fileRes.status}`); | |
| // Derive a key | |
| const key = nextUrl.substring(nextUrl.lastIndexOf('/') + 1); | |
| // Stream to R2 | |
| await env.R2.put(key, fileRes.body, { | |
| httpMetadata: { | |
| contentType: fileRes.headers.get("content-type") || undefined, | |
| contentDisposition: fileRes.headers.get("content-disposition") || undefined, | |
| }, | |
| customMetadata: { | |
| source: nextUrl, | |
| fetched_at: new Date().toISOString(), | |
| }, | |
| }); | |
| // Persist state | |
| await env.KV.put("url", nextUrl); | |
| // refresh index.html (cheap list + single put) | |
| await writeIndexHtml(env); | |
| } | |
| /* ---------------- index.html writer ---------------- */ | |
| async function writeIndexHtml(env) { | |
| const objects = await listAll(env); | |
| const html = renderIndexHtml({ | |
| title: "Files", | |
| objects, | |
| }); | |
| await env.R2.put('index.html', html, { | |
| httpMetadata: { contentType: "text/html; charset=utf-8" }, | |
| }); | |
| } | |
| async function listAll(env) { | |
| const out = []; | |
| let cursor; | |
| do { | |
| const page = await env.R2.list({ cursor, limit: 1000 }); | |
| out.push(...page.objects); | |
| cursor = page.truncated ? page.cursor : undefined; | |
| } while (cursor); | |
| return out; | |
| } | |
| function renderIndexHtml({ title, objects }) { | |
| const rows = objects | |
| .map((o) => { | |
| const name = o.key; | |
| const href = encodePath(name); // relative link -> fetches same prefix | |
| const size = formatBytes(o.size || 0); | |
| const ts = o.uploaded ? isoLocal(o.uploaded) : ""; | |
| return `<tr> | |
| <td><a href="${href}">${escapeHtml(name)}</a></td> | |
| <td class="num">${size}</td> | |
| <td class="mono">${ts}</td> | |
| </tr>`; | |
| }) | |
| .join(""); | |
| return `<!doctype html> | |
| <html lang="en"> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>${escapeHtml(title)}</title> | |
| <style> | |
| :root{--fg:#111;--muted:#666;--bg:#fff;--line:#e5e7eb;--card:#fafafa} | |
| *{box-sizing:border-box}body{margin:0;font:14px/1.5 system-ui,Segoe UI,Roboto,Inter,Arial;color:var(--fg);background:var(--bg)} | |
| header{padding:16px 20px;border-bottom:1px solid var(--line);background:var(--card)} | |
| h1{margin:0;font-size:16px} | |
| main{padding:16px 20px} | |
| table{width:100%;border-collapse:collapse} | |
| th,td{padding:10px;border-bottom:1px solid var(--line)} | |
| th{text-align:left;color:var(--muted);font-weight:600} | |
| td a{color:#0f62fe;text-decoration:none}td a:hover{text-decoration:underline} | |
| .num{text-align:right}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,Monaco,monospace;color:var(--muted)} | |
| .empty{padding:24px;color:var(--muted)} | |
| @media (max-width:640px){ .mono{display:none} th:nth-child(3), td:nth-child(3){display:none} } | |
| </style> | |
| <header><h1>${escapeHtml(title)}</h1></header> | |
| <main> | |
| ${objects.length | |
| ? `<table><thead><tr><th>File</th><th class="num">Size</th><th class="mono">Uploaded</th></tr></thead><tbody>${rows}</tbody></table>` | |
| : `<div class="empty">No files yet.</div>`} | |
| </main> | |
| </html>`; | |
| } | |
| /* ---------------- helpers ---------------- */ | |
| function encodePath(path) { | |
| return path.split("/").map(encodeURIComponent).join("/"); | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c])); | |
| } | |
| function formatBytes(n) { | |
| const u = ["B","KB","MB","GB","TB"]; let i=0, v=n; | |
| while (v >= 1024 && i < u.length-1) { v/=1024; i++; } | |
| return `${v.toFixed(v<10&&i>0?1:0)} ${u[i]}`; | |
| } | |
| function isoLocal(d) { | |
| const dt = new Date(d); const z = (x)=>String(x).padStart(2,"0"); | |
| return `${dt.getFullYear()}-${z(dt.getMonth()+1)}-${z(dt.getDate())} ${z(dt.getHours())}:${z(dt.getMinutes())}:${z(dt.getSeconds())}`; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment