Add Cloudflare Worker backend (D1 + KV overlay API), move rp.jarviscli.dev off GitHub Pages onto Workers
This commit is contained in:
parent
299005f062
commit
1fa361429a
8 changed files with 1726 additions and 8 deletions
17
.github/workflows/deploy.yml
vendored
17
.github/workflows/deploy.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
|
|
|
|||
1537
api/package-lock.json
generated
Normal file
1537
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
12
api/package.json
Normal file
12
api/package.json
Normal 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
11
api/schema.sql
Normal 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
123
api/src/index.ts
Normal 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
16
api/wrangler.toml
Normal 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"
|
||||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue