Files
magpie/site/worker.js
T

142 lines
5.7 KiB
JavaScript

// usemagpie.ai. Releases live on GitHub (yetone/magpie-releases); this
// worker turns the newest one into the update feed the app reads and into
// download links that never go stale.
//
// /api/latest {version, notes, url, published, assets: {name: {url, size, sha256}}}
// /download the Apple Silicon dmg
// /download/mac-arm64 the same; /download/mac-intel the Intel dmg
// /download/windows the Windows app (x64); /download/windows-arm64
// /download/linux the Linux app (x86-64); /download/linux-arm64
// /download/<file> any file of the newest release, by name
// /docs, /docs/zh the getting-started guide: /docs/start, /docs/zh/start
//
// Everything else is the static site in public/.
const REPO = "yetone/magpie-releases";
const TTL = 300; // seconds the newest release is remembered
const SHORT = {
"mac-arm64": "magpie-darwin-arm64.dmg",
"mac-intel": "magpie-darwin-amd64.dmg",
"mac-amd64": "magpie-darwin-amd64.dmg",
windows: "magpie-windows-amd64.exe",
"windows-amd64": "magpie-windows-amd64.exe",
"windows-arm64": "magpie-windows-arm64.exe",
linux: "magpie-linux-amd64",
"linux-amd64": "magpie-linux-amd64",
"linux-arm64": "magpie-linux-arm64",
};
// The bare docs paths open the getting-started guide.
const DOCS = { "/docs": "/docs/start", "/docs/zh": "/docs/zh/start" };
export default {
async fetch(req, env, ctx) {
const url = new URL(req.url);
if (url.hostname.startsWith("www.")) {
url.hostname = url.hostname.slice(4);
return Response.redirect(url.toString(), 301);
}
if (url.pathname === "/api/latest") {
const rel = await latest(ctx);
if (!rel) return json({ error: "no release yet" }, 503);
return json(rel, 200, { "Cache-Control": `public, max-age=${TTL}` });
}
if (url.pathname === "/download" || url.pathname.startsWith("/download/")) {
const want = url.pathname.split("/")[2] || "mac-arm64";
const rel = await latest(ctx);
const asset = rel && rel.assets[SHORT[want] || want];
if (!asset) return new Response("not found\n", { status: 404 });
return Response.redirect(asset.url, 302);
}
const guide = DOCS[url.pathname.replace(/\/+$/, "")];
if (guide) return Response.redirect(new URL(guide, url).toString(), 302);
return beacon(await env.ASSETS.fetch(req));
},
};
// beacon adds Cloudflare Web Analytics to a page. The dashboard's automatic
// injection skips whatever a worker returns, and every page passes through
// this one.
const BEACON = `<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "c09e76abf16b40b2aa5571c196efb847"}'></script>`;
function beacon(res) {
if (!(res.headers.get("Content-Type") || "").startsWith("text/html")) return res;
return new HTMLRewriter().on("body", { element: (el) => el.append(BEACON, { html: true }) }).transform(res);
}
// latest is the newest release, condensed, with each file's SHA-256 taken
// from the release's SHA256SUMS. GitHub's API gives the notes and sizes but
// limits anonymous callers by IP, and a worker shares its IP with many
// others; when the API says no, the release page's redirect gives the
// version and SHA256SUMS the files, which is all an update needs.
async function latest(ctx) {
const cache = caches.default;
const key = new Request("https://usemagpie.ai/__latest");
const hit = await cache.match(key);
if (hit) return hit.json();
const rel = (await fromAPI()) || (await fromPages());
if (!rel) return null;
ctx.waitUntil(cache.put(key, json(rel, 200, { "Cache-Control": `max-age=${TTL}` })));
return rel;
}
const UA = { "User-Agent": "usemagpie.ai" };
async function fromAPI() {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
headers: { ...UA, Accept: "application/vnd.github+json" },
});
if (!res.ok) {
console.log("github api", res.status, await res.text());
return null;
}
const gh = await res.json();
const tag = gh.tag_name;
const sums = await checksums(tag);
if (!sums) return null;
const rel = { version: tag.replace(/^v/, ""), notes: gh.body || "", url: gh.html_url, published: gh.published_at, assets: {} };
for (const a of gh.assets) {
if (a.name === "SHA256SUMS") continue;
rel.assets[a.name] = { url: a.browser_download_url, size: a.size, sha256: sums[a.name] || "" };
}
return rel;
}
async function fromPages() {
const res = await fetch(`https://github.com/${REPO}/releases/latest`, { headers: UA, redirect: "manual" });
const tag = (res.headers.get("Location") || "").split("/tag/")[1];
if (!tag) {
console.log("github latest redirect", res.status);
return null;
}
const sums = await checksums(tag);
if (!sums) return null;
const url = `https://github.com/${REPO}/releases/tag/${tag}`;
const rel = { version: tag.replace(/^v/, ""), notes: "", url, published: null, assets: {} };
for (const [name, sha256] of Object.entries(sums)) {
rel.assets[name] = { url: `https://github.com/${REPO}/releases/download/${tag}/${name}`, size: 0, sha256 };
}
return rel;
}
// checksums reads a release's SHA256SUMS: {file name: hash}.
async function checksums(tag) {
const r = await fetch(`https://github.com/${REPO}/releases/download/${tag}/SHA256SUMS`, { headers: UA });
if (!r.ok) return null;
const sums = {};
for (const line of (await r.text()).split("\n")) {
const [hash, name] = line.trim().split(/\s+\*?/);
if (hash && name) sums[name] = hash;
}
return sums;
}
function json(v, status = 200, headers = {}) {
return new Response(JSON.stringify(v), {
status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", ...headers },
});
}