Make every plugin field live, and support draft plugins that bypass git entirely
This commit is contained in:
parent
420bc89ad3
commit
90eb694948
4 changed files with 285 additions and 91 deletions
|
|
@ -1,11 +1,28 @@
|
|||
-- 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 (
|
||||
-- Every displayed field can now be overridden here, not just status/broken/tags - editing anything
|
||||
-- from the admin plugin should show up on the live site immediately, no git push needed. A row can
|
||||
-- also stand entirely on its own (is_draft = 1): a plugin that doesn't exist in the git repo at all,
|
||||
-- created straight from the admin plugin for quick testing, with its own manifest/JS served directly
|
||||
-- from here instead of from the built dist/ output.
|
||||
DROP TABLE IF EXISTS overlay;
|
||||
CREATE TABLE overlay (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
status TEXT,
|
||||
accent TEXT,
|
||||
tagline TEXT,
|
||||
note TEXT,
|
||||
how_it_works TEXT,
|
||||
features TEXT, -- JSON array of strings
|
||||
commands TEXT, -- JSON array of {cmd, desc}
|
||||
limitations TEXT,
|
||||
authors TEXT, -- JSON array of strings
|
||||
broken_reason TEXT,
|
||||
broken_since TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
is_draft INTEGER NOT NULL DEFAULT 0,
|
||||
manifest TEXT, -- full manifest.json content - only used when is_draft = 1
|
||||
main_js TEXT, -- index.js source - only used when is_draft = 1
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
|
|
|||
326
api/src/index.ts
326
api/src/index.ts
|
|
@ -5,15 +5,58 @@ export interface Env {
|
|||
ADMIN_TOKEN: string;
|
||||
}
|
||||
|
||||
interface OverlayEntry {
|
||||
status?: string | null;
|
||||
broken?: { reason: string; since?: string } | null;
|
||||
tags?: string[];
|
||||
interface Command {
|
||||
cmd: string;
|
||||
desc: 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";
|
||||
// Every field is optional - a PUT only needs to send what it wants to change, and whatever isn't
|
||||
// sent falls back to the git-tracked base (for a plugin that exists in git) or a sane default (for
|
||||
// a brand-new draft-only plugin, which has no base to fall back to at all).
|
||||
interface OverlayEntry {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
status?: string | null;
|
||||
accent?: string;
|
||||
tagline?: string;
|
||||
note?: string;
|
||||
howItWorks?: string;
|
||||
features?: string[];
|
||||
commands?: Command[];
|
||||
limitations?: string;
|
||||
authors?: string[];
|
||||
broken?: { reason: string; since?: string } | null;
|
||||
tags?: string[];
|
||||
draft?: boolean;
|
||||
mainJs?: string; // raw plugin source - only meaningful for a draft, ignored otherwise
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
status: string | null;
|
||||
accent: string | null;
|
||||
tagline: string | null;
|
||||
note: string | null;
|
||||
how_it_works: string | null;
|
||||
features: string | null;
|
||||
commands: string | null;
|
||||
limitations: string | null;
|
||||
authors: string | null;
|
||||
broken_reason: string | null;
|
||||
broken_since: string | null;
|
||||
tags: string;
|
||||
is_draft: number;
|
||||
manifest: string | null;
|
||||
main_js: string | null;
|
||||
}
|
||||
|
||||
// Short TTL, not a long-lived cache - the whole point of this overlay is that a change from a phone
|
||||
// should be visible almost immediately, not after a long edge-cache window.
|
||||
const CACHE_KEY = "overlay:v2";
|
||||
const CACHE_TTL_SECONDS = 60; // Workers KV's enforced minimum
|
||||
|
||||
const CORS_HEADERS = {
|
||||
|
|
@ -29,22 +72,52 @@ function json(data: unknown, status = 200): Response {
|
|||
});
|
||||
}
|
||||
|
||||
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) : [],
|
||||
};
|
||||
}
|
||||
function rowToOverlay(row: Row): OverlayEntry & { draft: boolean } {
|
||||
return {
|
||||
name: row.name ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
category: row.category ?? undefined,
|
||||
status: row.status ?? undefined,
|
||||
accent: row.accent ?? undefined,
|
||||
tagline: row.tagline ?? undefined,
|
||||
note: row.note ?? undefined,
|
||||
howItWorks: row.how_it_works ?? undefined,
|
||||
features: row.features ? JSON.parse(row.features) : undefined,
|
||||
commands: row.commands ? JSON.parse(row.commands) : undefined,
|
||||
limitations: row.limitations ?? undefined,
|
||||
authors: row.authors ? JSON.parse(row.authors) : undefined,
|
||||
broken: row.broken_reason ? { reason: row.broken_reason, since: row.broken_since ?? undefined } : null,
|
||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||
draft: !!row.is_draft,
|
||||
};
|
||||
}
|
||||
|
||||
async function readAll(db: D1Database): Promise<Record<string, OverlayEntry & { draft: boolean }>> {
|
||||
const { results } = await db.prepare("SELECT * FROM overlay").all<Row>();
|
||||
const out: Record<string, OverlayEntry & { draft: boolean }> = {};
|
||||
for (const row of results) out[row.id] = rowToOverlay(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Shared by every read path (GET /api/overlay, GET /plugins-data.json, and install-file serving for
|
||||
// draft plugins) so a change from the admin plugin shows up everywhere on the next request, not just
|
||||
// on the raw overlay endpoint.
|
||||
async function getOverlay(env: Env): Promise<Record<string, OverlayEntry & { draft: boolean }>> {
|
||||
const cached = await env.OVERLAY_CACHE.get(CACHE_KEY, "json");
|
||||
if (cached) return cached as Record<string, OverlayEntry & { draft: boolean }>;
|
||||
|
||||
const data = await readAll(env.DB);
|
||||
await env.OVERLAY_CACHE.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL_SECONDS });
|
||||
return data;
|
||||
}
|
||||
|
||||
async function sha256Hex(text: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
||||
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const DEFAULT_MAIN_JS = `export default {\n onLoad: () => {},\n onUnload: () => {}\n};\n`;
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
|
|
@ -57,67 +130,180 @@ export default {
|
|||
};
|
||||
|
||||
async function handle(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
// The site's actual data file - computed fresh on every request by merging the git-tracked base
|
||||
// (plugins-base.json, written by generate-site.mjs at build time) with the live overlay, so any
|
||||
// change from the admin plugin is visible immediately, with no rebuild. Draft-only plugins (no
|
||||
// git base at all) are appended as their own entries.
|
||||
if (url.pathname === "/plugins-data.json") {
|
||||
const baseRes = await env.ASSETS.fetch(new URL("/plugins-base.json", request.url));
|
||||
const base = baseRes.ok ? await baseRes.json<any[]>() : [];
|
||||
const overlay = await getOverlay(env);
|
||||
const baseIds = new Set(base.map((p) => p.id));
|
||||
|
||||
const merged = base.map((p) => applyOverride(p, overlay[p.id]));
|
||||
const draftOnly = Object.entries(overlay)
|
||||
.filter(([id, o]) => o.draft && !baseIds.has(id))
|
||||
.map(([id, o]) => synthesizeDraft(id, o));
|
||||
|
||||
return json([...merged, ...draftOnly]);
|
||||
}
|
||||
|
||||
// Dynamic install files for draft-only plugins (created via PUT /api/overlay/:id with draft:true
|
||||
// and mainJs). Checked against D1 *first*, not by probing the static asset's response status -
|
||||
// with SPA fallback enabled (see wrangler.toml's not_found_handling), a missing asset no longer
|
||||
// 404s, it serves index.html with a 200, so that status can't be used to detect "not found" here.
|
||||
// A real git-tracked plugin has no row here, so this always falls through to ASSETS for it.
|
||||
const installMatch = url.pathname.match(/^\/([\w-]+)\/install\/(manifest\.json|index\.js)$/);
|
||||
if (installMatch) {
|
||||
const [, id, file] = installMatch;
|
||||
const row = await env.DB.prepare("SELECT manifest, main_js FROM overlay WHERE id = ?1 AND is_draft = 1")
|
||||
.bind(id).first<{ manifest: string; main_js: string }>();
|
||||
|
||||
if (row) {
|
||||
if (file === "manifest.json") return json(JSON.parse(row.manifest));
|
||||
return new Response(row.main_js, { headers: { "content-type": "application/javascript", ...CORS_HEADERS } });
|
||||
}
|
||||
|
||||
if (!url.pathname.startsWith("/api/")) {
|
||||
return env.ASSETS.fetch(request);
|
||||
return env.ASSETS.fetch(request);
|
||||
}
|
||||
|
||||
if (!url.pathname.startsWith("/api/")) {
|
||||
return env.ASSETS.fetch(request);
|
||||
}
|
||||
|
||||
// GET /api/overlay - public read, used by the admin plugin/site to show current values before
|
||||
// editing.
|
||||
if (url.pathname === "/api/overlay" && request.method === "GET") {
|
||||
return json(await getOverlay(env));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (request.method === "PUT") {
|
||||
const body = await request.json<OverlayEntry>().catch(() => null);
|
||||
if (!body) return json({ error: "invalid body" }, 400);
|
||||
|
||||
const data = await readAll(env.DB);
|
||||
await env.OVERLAY_CACHE.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL_SECONDS });
|
||||
return json(data);
|
||||
}
|
||||
const existing = await env.DB.prepare("SELECT manifest, main_js FROM overlay WHERE id = ?1").bind(id).first<{ manifest: string; main_js: string }>();
|
||||
|
||||
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);
|
||||
let manifest = existing?.manifest ?? null;
|
||||
let mainJs = existing?.main_js ?? null;
|
||||
if (body.draft) {
|
||||
mainJs = body.mainJs ?? mainJs ?? DEFAULT_MAIN_JS;
|
||||
const manifestObj = {
|
||||
name: body.name ?? id,
|
||||
description: body.description ?? "",
|
||||
authors: (body.authors ?? []).map((n) => ({ name: n })),
|
||||
main: "index.js",
|
||||
hash: await sha256Hex(mainJs),
|
||||
};
|
||||
manifest = JSON.stringify(manifestObj);
|
||||
}
|
||||
|
||||
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, name, description, category, status, accent, tagline, note, how_it_works,
|
||||
features, commands, limitations, authors, broken_reason, broken_since, tags,
|
||||
is_draft, manifest, main_js, updated_at
|
||||
) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, description = excluded.description, category = excluded.category,
|
||||
status = excluded.status, accent = excluded.accent, tagline = excluded.tagline,
|
||||
note = excluded.note, how_it_works = excluded.how_it_works, features = excluded.features,
|
||||
commands = excluded.commands, limitations = excluded.limitations, authors = excluded.authors,
|
||||
broken_reason = excluded.broken_reason, broken_since = excluded.broken_since, tags = excluded.tags,
|
||||
is_draft = excluded.is_draft, manifest = excluded.manifest, main_js = excluded.main_js,
|
||||
updated_at = excluded.updated_at`,
|
||||
).bind(
|
||||
id,
|
||||
body.name ?? null,
|
||||
body.description ?? null,
|
||||
body.category ?? null,
|
||||
body.status ?? null,
|
||||
body.accent ?? null,
|
||||
body.tagline ?? null,
|
||||
body.note ?? null,
|
||||
body.howItWorks ?? null,
|
||||
body.features ? JSON.stringify(body.features) : null,
|
||||
body.commands ? JSON.stringify(body.commands) : null,
|
||||
body.limitations ?? null,
|
||||
body.authors ? JSON.stringify(body.authors) : null,
|
||||
body.broken?.reason ?? null,
|
||||
body.broken ? (body.broken.since ?? new Date().toISOString()) : null,
|
||||
JSON.stringify(body.tags ?? []),
|
||||
body.draft ? 1 : 0,
|
||||
manifest,
|
||||
mainJs,
|
||||
new Date().toISOString(),
|
||||
).run();
|
||||
|
||||
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 });
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
function applyOverride(base: any, over: (OverlayEntry & { draft: boolean }) | undefined) {
|
||||
if (!over) return { ...base, broken: null, tags: [] };
|
||||
return {
|
||||
...base,
|
||||
name: over.name ?? base.name,
|
||||
description: over.description ?? base.description,
|
||||
category: over.category ?? base.category,
|
||||
status: over.status ?? base.status,
|
||||
accent: over.accent ?? base.accent,
|
||||
tagline: over.tagline ?? base.tagline,
|
||||
note: over.note ?? base.note,
|
||||
howItWorks: over.howItWorks ?? base.howItWorks,
|
||||
features: over.features ?? base.features,
|
||||
commands: over.commands ?? base.commands,
|
||||
limitations: over.limitations ?? base.limitations,
|
||||
authors: over.authors ?? base.authors,
|
||||
broken: over.broken ?? null,
|
||||
tags: over.tags ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function synthesizeDraft(id: string, over: OverlayEntry & { draft: boolean }) {
|
||||
const base = "https://rp.jarviscli.dev";
|
||||
return {
|
||||
id,
|
||||
name: over.name ?? id,
|
||||
description: over.description ?? "",
|
||||
authors: over.authors ?? [],
|
||||
category: over.category ?? "Other",
|
||||
status: over.status ?? "new",
|
||||
broken: over.broken ?? null,
|
||||
tags: over.tags ?? [],
|
||||
accent: over.accent ?? "#5865f2",
|
||||
tagline: over.tagline ?? over.description ?? "",
|
||||
note: over.note ?? "Draft plugin - created from the admin plugin, not tracked in git.",
|
||||
howItWorks: over.howItWorks ?? "",
|
||||
features: over.features ?? [],
|
||||
commands: over.commands ?? [],
|
||||
limitations: over.limitations ?? "",
|
||||
pageUrl: `${base}/${id}/`,
|
||||
installUrl: `${base}/${id}/install/`,
|
||||
sourceUrl: "https://github.com/everestmcarthur/revenge-plugins",
|
||||
issueUrl: `https://github.com/everestmcarthur/revenge-plugins/issues/new`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ compatibility_date = "2024-11-01"
|
|||
[assets]
|
||||
directory = "../dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
|
|
|
|||
|
|
@ -6,18 +6,11 @@ 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.");
|
||||
}
|
||||
|
||||
// This file intentionally does NOT merge in the live overlay (status/broken/tags) anymore - that
|
||||
// used to happen here at build time, which meant flipping something broken from the admin plugin
|
||||
// only showed up on the site after the next git push. The Worker (api/src/index.ts) now merges
|
||||
// plugins-base.json (written below, git-tracked content only) with the live overlay on every
|
||||
// request to /plugins-data.json, so admin changes are visible immediately with no rebuild needed.
|
||||
for (const id of await readdir("./dist")) {
|
||||
const dir = `./dist/${id}`;
|
||||
if (!(await stat(dir)).isDirectory()) continue;
|
||||
|
|
@ -30,7 +23,6 @@ for (const id of await readdir("./dist")) {
|
|||
}
|
||||
|
||||
const info = meta[id] ?? {};
|
||||
const over = overlay[id] ?? {};
|
||||
|
||||
plugins.push({
|
||||
id,
|
||||
|
|
@ -38,9 +30,7 @@ for (const id of await readdir("./dist")) {
|
|||
description: manifest.description,
|
||||
authors: (manifest.authors ?? []).map((a) => a.name),
|
||||
category: info.category ?? "Other",
|
||||
status: over.status ?? info.status ?? "new",
|
||||
broken: over.broken ?? null,
|
||||
tags: over.tags ?? [],
|
||||
status: info.status ?? "new",
|
||||
accent: info.accent ?? "#5865f2",
|
||||
tagline: info.tagline ?? manifest.description,
|
||||
note: info.note ?? "",
|
||||
|
|
@ -57,7 +47,7 @@ for (const id of await readdir("./dist")) {
|
|||
|
||||
plugins.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
|
||||
|
||||
await writeFile("./dist/plugins-data.json", JSON.stringify(plugins, null, 2));
|
||||
await writeFile("./dist/plugins-base.json", JSON.stringify(plugins, null, 2));
|
||||
await copyFile("./site/index.html", "./dist/index.html");
|
||||
await copyFile("./site/index.html", "./dist/404.html");
|
||||
await copyFile("./site/CNAME", "./dist/CNAME");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue