Add Cloudflare Worker backend (D1 + KV overlay API), move rp.jarviscli.dev off GitHub Pages onto Workers

This commit is contained in:
Ally Founder 2026-08-02 14:31:26 -04:00
parent 299005f062
commit 1fa361429a
8 changed files with 1726 additions and 8 deletions

View file

@ -7,8 +7,6 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
@ -24,8 +22,13 @@ jobs:
- name: Build plugin(s) and site
run: npm run build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
- name: Install API deps
working-directory: api
run: npm install
- name: Deploy to Cloudflare Workers
working-directory: api
env:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
run: npx wrangler deploy

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
node_modules
dist
.wrangler

1537
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

12
api/package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "revenge-plugins-api",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"migrate": "wrangler d1 execute revenge-plugins-overlay --remote --file=./schema.sql"
},
"devDependencies": {
"wrangler": "^4.0.0"
}
}

11
api/schema.sql Normal file
View file

@ -0,0 +1,11 @@
-- The overlay is deliberately thin: site/meta.json (git-tracked) stays the source of truth for
-- real plugin content (descriptions, features, howItWorks). This table only holds the bits meant
-- to be flipped from a phone without a git push: status label, a "broken" flag + reason, and tags.
CREATE TABLE IF NOT EXISTS overlay (
id TEXT PRIMARY KEY,
status TEXT,
broken_reason TEXT,
broken_since TEXT,
tags TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);

123
api/src/index.ts Normal file
View file

@ -0,0 +1,123 @@
export interface Env {
ASSETS: Fetcher;
DB: D1Database;
OVERLAY_CACHE: KVNamespace;
ADMIN_TOKEN: string;
}
interface OverlayEntry {
status?: string | null;
broken?: { reason: string; since?: string } | null;
tags?: string[];
}
// Short TTL, not a long-lived cache - the whole point of this overlay is that a status/broken/tag
// flip from a phone should be visible almost immediately, not after a long edge-cache window.
const CACHE_KEY = "overlay:v1";
const CACHE_TTL_SECONDS = 60; // Workers KV's enforced minimum
const CORS_HEADERS = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET,PUT,DELETE,OPTIONS",
"access-control-allow-headers": "authorization,content-type",
};
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json", ...CORS_HEADERS },
});
}
async function readAll(db: D1Database): Promise<Record<string, OverlayEntry>> {
const { results } = await db.prepare("SELECT * FROM overlay").all<Record<string, unknown>>();
const out: Record<string, OverlayEntry> = {};
for (const row of results) {
const id = row.id as string;
out[id] = {
status: (row.status as string) ?? undefined,
broken: row.broken_reason
? { reason: row.broken_reason as string, since: row.broken_since as string }
: null,
tags: row.tags ? JSON.parse(row.tags as string) : [],
};
}
return out;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
return await handle(request, env);
} catch (e) {
console.error(e);
return json({ error: "internal" }, 500);
}
},
};
async function handle(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
return new Response(null, { headers: CORS_HEADERS });
}
if (!url.pathname.startsWith("/api/")) {
return env.ASSETS.fetch(request);
}
// GET /api/overlay - public read, used at site build time (generate-site.mjs) and by the
// admin plugin/site to show current values before editing.
if (url.pathname === "/api/overlay" && request.method === "GET") {
const cached = await env.OVERLAY_CACHE.get(CACHE_KEY, "json");
if (cached) return json(cached);
const data = await readAll(env.DB);
await env.OVERLAY_CACHE.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL_SECONDS });
return json(data);
}
const match = url.pathname.match(/^\/api\/overlay\/([\w-]+)$/);
if (match) {
const id = match[1];
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${env.ADMIN_TOKEN}`) {
return json({ error: "unauthorized" }, 401);
}
if (request.method === "PUT") {
const body = await request.json<OverlayEntry>().catch(() => null);
if (!body) return json({ error: "invalid body" }, 400);
await env.DB.prepare(
`INSERT INTO overlay (id, status, broken_reason, broken_since, tags, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
broken_reason = excluded.broken_reason,
broken_since = excluded.broken_since,
tags = excluded.tags,
updated_at = excluded.updated_at`,
).bind(
id,
body.status ?? null,
body.broken?.reason ?? null,
body.broken ? (body.broken.since ?? new Date().toISOString()) : null,
JSON.stringify(body.tags ?? []),
new Date().toISOString(),
).run();
await env.OVERLAY_CACHE.delete(CACHE_KEY);
return json({ ok: true });
}
if (request.method === "DELETE") {
await env.DB.prepare("DELETE FROM overlay WHERE id = ?1").bind(id).run();
await env.OVERLAY_CACHE.delete(CACHE_KEY);
return json({ ok: true });
}
}
return json({ error: "not found" }, 404);
}

16
api/wrangler.toml Normal file
View file

@ -0,0 +1,16 @@
name = "revenge-plugins"
main = "src/index.ts"
compatibility_date = "2024-11-01"
[assets]
directory = "../dist"
binding = "ASSETS"
[[d1_databases]]
binding = "DB"
database_name = "revenge-plugins-overlay"
database_id = "639fef3c-6583-4128-adbd-b5f5f4761483"
[[kv_namespaces]]
binding = "OVERLAY_CACHE"
id = "e56d183bdcd64f5facade1c3fe04eb08"

View file

@ -6,6 +6,18 @@ const REPO_BASE = "https://github.com/everestmcarthur/revenge-plugins";
const meta = JSON.parse(await readFile("./site/meta.json", "utf8"));
const plugins = [];
// The overlay holds the fast-changing bits (status, "broken" flag/reason, tags) meant to be flipped
// from the admin plugin without a git push - meta.json above stays the source of truth for real
// content. A fetch failure here (API down, first-ever build before it exists) must never break the
// public build, so this always falls back to an empty overlay.
let overlay = {};
try {
const res = await fetch(`${PAGES_BASE}/api/overlay`);
if (res.ok) overlay = await res.json();
} catch {
console.warn("Couldn't reach the overlay API - building with meta.json alone.");
}
for (const id of await readdir("./dist")) {
const dir = `./dist/${id}`;
if (!(await stat(dir)).isDirectory()) continue;
@ -18,6 +30,7 @@ for (const id of await readdir("./dist")) {
}
const info = meta[id] ?? {};
const over = overlay[id] ?? {};
plugins.push({
id,
@ -25,7 +38,9 @@ for (const id of await readdir("./dist")) {
description: manifest.description,
authors: (manifest.authors ?? []).map((a) => a.name),
category: info.category ?? "Other",
status: info.status ?? "new",
status: over.status ?? info.status ?? "new",
broken: over.broken ?? null,
tags: over.tags ?? [],
accent: info.accent ?? "#5865f2",
tagline: info.tagline ?? manifest.description,
note: info.note ?? "",