Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Move header injection to open-graph worker
Rūdolfs Ošiņš committed 15 days ago
commit 78ccb93d84829fd4919acafc844eb645f41ffcb4
parent 74e15e2
5 files changed +169 -244
modified workers/README.md
@@ -1,84 +1,58 @@
-
# OG Image Generation
+
# Open Graph

-
Two components work together to serve Open Graph preview images for
-
social media embeds (Twitter/X, Slack, Discord, etc.).
+
Worker on `open-graph.radicle.network` that serves OG meta tags and
+
preview card images for social media embeds.

-
## Components
+
## How it works

-
**og-injector** (`og-injector.js`) runs as middleware on the app itself
-
(Cloudflare Workers). It injects `<meta og:image>`, `<meta og:title>`,
-
and related Open Graph tags into every HTML response, pointing to the
-
OG image worker.
+
`app.radicle.xyz` is a plain static SPA. A Cloudflare Redirect Rule
+
302s social media crawlers to `open-graph.radicle.network`, which
+
returns HTML with OG tags. The `og:image` points to `/cards/...` on
+
the same domain, where the worker renders PNG cards via Satori + resvg-wasm.

-
**open-graph** (`open-graph/index.js`) is a standalone Cloudflare
-
Worker that generates PNG cards on the fly using Satori (HTML/CSS to
-
SVG) and resvg-wasm (SVG to PNG). It fetches data from the radicle-httpd
-
API, generates procedural avatars, and caches results at the edge.
-

-
Request flow: request hits app -> og-injector injects og:image URL ->
-
social crawler fetches image from open-graph worker -> worker calls
-
httpd API, renders PNG, caches and returns it.
+
Browsers and search crawlers hit the SPA directly — no worker cost.

## Routes

-
| URL pattern | Card type |
-
|-------------|-----------|
-
| `/` | Home (forest image + wordmark) |
-
| `/nodes/:host` | Node (avatar + hostname + stats) |
-
| `/nodes/:host/users/:did` | User (avatar + alias + DID) |
-
| `/nodes/:host/:rid` | Repository (avatar + name + stats + delegates) |
-
| `/nodes/:host/:rid/issues` | Issues listing (open + closed counts) |
-
| `/nodes/:host/:rid/patches` | Patches listing (open/draft/archived/merged counts) |
-
| `/nodes/:host/:rid/history` | Commit history (branch + commit count) |
-
| `/nodes/:host/:rid/remotes/:peer/history/:branch?` | Commit history (peer branch) |
-
| `/nodes/:host/:rid/issues/:id` | Single issue (state pill + title + author) |
-
| `/nodes/:host/:rid/patches/:id` | Single patch (state pill + title + author) |
-
| `/nodes/:host/:rid/commits/:sha` | Single commit (title + author/committer gravatars) |
-

-
## Fallback chain
-

-
When data fetching fails, cards degrade gracefully:
-

-
    single issue/patch/commit -> repo card -> node card
-
    issues/patches/history listing -> repo card -> node card
-
    repo card -> node card
-
    node/user/home -> always renders
-

-
## Fonts
-

-
Two weights of Booton are bundled as worker data assets:
-
- `Booton-Regular.ttf` (400) for body text
-
- `Booton-SemiBold.ttf` (600) for headings and labels
+
- `/*` — OG HTML with meta tags (cached 2 hours)
+
- `/cards/*` — PNG card images (cached 2 hours)

-
## Caching
+
Card routes mirror app routes: `/cards/nodes/:host/:rid`,
+
`/cards/nodes/:host/:rid/issues/:id`, etc. When API fetches fail,
+
cards degrade: specific item -> repo -> node -> home.

-
Two layers of caching:
+
## Cloudflare Redirect Rule

-
- **open-graph worker**: Uses Cloudflare's Cache API (`caches.default`)
-
  at the edge. Responses carry `Cache-Control: public, max-age=120`
-
  (2 minutes).
-
- **og-injector**: Injected HTML responses carry
-
  `Cache-Control: public, max-age=300` (5 minutes).
+
**Rules > Redirect Rules** on the `radicle.xyz` zone:

-
## Deployment
+
```
+
(http.host eq "app.radicle.xyz") and (
+
  (http.user_agent contains "Twitterbot") or
+
  (http.user_agent contains "facebookexternalhit") or
+
  (http.user_agent contains "Facebot") or
+
  (http.user_agent contains "LinkedInBot") or
+
  (http.user_agent contains "Slackbot") or
+
  (http.user_agent contains "Discordbot") or
+
  (http.user_agent contains "TelegramBot") or
+
  (http.user_agent contains "WhatsApp") or
+
  (http.user_agent contains "Pinterestbot") or
+
  (http.user_agent contains "ZulipURLPreview") or
+
  (http.user_agent contains "Bluesky Cardyb")
+
)
+
```

-
### og-injector
+
Dynamic 302 to `concat("https://open-graph.radicle.network", http.request.uri.path)`

-
Deployed as part of the Cloudflare Pages project via `npm run deploy`.
-
The `OG_IMAGE_BASE` env var is set to the open-graph worker origin
-
(`https://open-graph.radicle.network`).
+
## Rate Limiting

-
### open-graph worker
+
**Security > WAF > Rate limiting rules** on the `radicle.network` zone:

-
```sh
-
npm run deploy:open-graph
-
```
+
Verified bots are rate limited to 50 requests per 10 seconds across
+
all of `radicle.network`, with a 1 minute block.

-
For local development:
+
## Deploy

```sh
-
npm run start:open-graph
+
npm run deploy:open-graph   # worker
+
npm run start:open-graph    # local dev
```
-

-
The worker bundles fonts (`.ttf`), the forest image (`.jpg`), and the
-
resvg WASM module as data assets configured in `wrangler.toml`.
deleted workers/og-injector.js
@@ -1,166 +0,0 @@
-
function parseRoute(pathname) {
-
  const segments = pathname.replace(/^\//, "").split("/");
-
  const first = segments[0];
-

-
  // Home
-
  if (first === "" || first === undefined) {
-
    return { type: "home" };
-
  }
-

-
  if (first !== "nodes" && first !== "seeds") return null;
-

-
  const host = segments[1];
-
  if (!host) return { type: "home" };
-

-
  const rid = segments[2];
-

-
  // /nodes/:host
-
  if (!rid) return { type: "node", host };
-

-
  // /nodes/:host/users/:did
-
  if (rid === "users") {
-
    const did = segments[3];
-
    return did ? { type: "user", host, did } : { type: "node", host };
-
  }
-

-
  // /nodes/:host/:rid/issues/:id
-
  if (segments[3] === "issues" && segments[4]) {
-
    return { type: "issue", host, rid, id: segments[4] };
-
  }
-
  // /nodes/:host/:rid/issues
-
  if (segments[3] === "issues") {
-
    return { type: "issues", host, rid };
-
  }
-
  // /nodes/:host/:rid/patches/:id
-
  if (segments[3] === "patches" && segments[4]) {
-
    return { type: "patch", host, rid, id: segments[4] };
-
  }
-
  // /nodes/:host/:rid/patches
-
  if (segments[3] === "patches") {
-
    return { type: "patches", host, rid };
-
  }
-
  // /nodes/:host/:rid/commits/:sha
-
  if (segments[3] === "commits" && segments[4]) {
-
    return { type: "commit", host, rid, sha: segments[4] };
-
  }
-

-
  // /nodes/:host/:rid/history/:branch
-
  if (segments[3] === "history") {
-
    return { type: "history", host, rid };
-
  }
-

-
  // /nodes/:host/:rid/remotes/:peer/history/:branch
-
  if (segments[3] === "remotes" && segments[4] && segments[5] === "history") {
-
    return { type: "history", host, rid };
-
  }
-

-
  // /nodes/:host/:rid — source, tree, etc. share the repo card
-
  return { type: "repo", host, rid };
-
}
-

-
function shortenDid(did) {
-
  const match = /^did:key:(z[a-zA-Z0-9]+)$/.exec(did);
-
  if (!match) return did;
-
  const pubkey = match[1];
-
  return `${pubkey.substring(0, 8)}…${pubkey.slice(-8)}`;
-
}
-

-
function titleForRoute(route) {
-
  switch (route.type) {
-
    case "home":
-
      return "Radicle Explorer · Decentralized Code Collaboration";
-
    case "node":
-
      return `Radicle Seed Node · ${route.host}`;
-
    case "user":
-
      return `Radicle User · ${shortenDid(route.did)} · ${route.host}`;
-
    case "repo":
-
      return `Radicle Repo · ${route.rid} · ${route.host}`;
-
    case "issues":
-
      return `Issues · ${route.rid} · ${route.host}`;
-
    case "patches":
-
      return `Patches · ${route.rid} · ${route.host}`;
-
    case "issue":
-
      return `Issue ${route.id.slice(0, 7)} · ${route.rid} · ${route.host}`;
-
    case "patch":
-
      return `Patch ${route.id.slice(0, 7)} · ${route.rid} · ${route.host}`;
-
    case "commit":
-
      return `Commit ${route.sha.slice(0, 7)} · ${route.rid} · ${route.host}`;
-
    case "history":
-
      return `Commit History · ${route.rid} · ${route.host}`;
-
    default:
-
      return "Radicle Explorer · Decentralized Code Collaboration";
-
  }
-
}
-

-
function descriptionForRoute(route) {
-
  const h = route.host;
-
  switch (route.type) {
-
    case "home":
-
      return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network. Sovereign hosting without central servers.";
-
    case "node":
-
      return `Browse repositories hosted on the ${h} Radicle seed node. Explore source code, issues, patches, and contributor activity.`;
-
    case "user":
-
      return `Radicle user profile on ${h}. Browse repositories, patches, and contributions across the peer-to-peer network.`;
-
    case "repo":
-
      return `Radicle repository hosted on ${h}. Browse source code, issues, patches, commit history, and contributor activity.`;
-
    case "issues":
-
      return `Browse issues for this repository on ${h}. View bug reports, feature requests, and ongoing discussions on the Radicle network.`;
-
    case "patches":
-
      return `Browse patches for this repository on ${h}. View proposed changes, code reviews, and merge status on the Radicle network.`;
-
    case "issue":
-
      return `View this issue and its discussion in a Radicle repository on ${h}. Track progress, comments, and resolution.`;
-
    case "patch":
-
      return `View this patch and its review in a Radicle repository on ${h}. Browse revisions, review comments, and merge status.`;
-
    case "commit":
-
      return `View this commit and its diff in a Radicle repository on ${h}. Inspect changed files, author details, and additions.`;
-
    case "history":
-
      return `Browse the commit history of this Radicle repository on ${h}. View commits, contributors, and development activity.`;
-
    default:
-
      return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network.";
-
  }
-
}
-

-
export default {
-
  async fetch(request, env) {
-
    const url = new URL(request.url);
-
    const route = parseRoute(url.pathname);
-

-
    if (!route) {
-
      return env.ASSETS.fetch(request);
-
    }
-

-
    const hostParam = route.type === "home" ? `?host=${url.hostname}` : "";
-
    const ogImageUrl = `${env.OG_IMAGE_BASE}${url.pathname}${hostParam}`;
-
    const title = titleForRoute(route);
-
    const description = descriptionForRoute(route);
-

-
    const indexResponse = await env.ASSETS.fetch(
-
      new Request(new URL("/", url)),
-
    );
-
    const html = await indexResponse.text();
-

-
    const tags = [
-
      `<meta property="og:title" content="${title}" />`,
-
      `<meta property="og:description" content="${description}" />`,
-
      `<meta property="og:image" content="${ogImageUrl}" />`,
-
      `<meta property="og:image:width" content="1200" />`,
-
      `<meta property="og:image:height" content="630" />`,
-
      `<meta property="og:url" content="${url.href}" />`,
-
      `<meta property="og:type" content="website" />`,
-
      `<meta property="og:site_name" content="Radicle Explorer" />`,
-
      `<meta name="twitter:card" content="summary_large_image" />`,
-
      `<meta name="twitter:site" content="@radicle" />`,
-
      `<meta name="theme-color" content="#1c77ff" />`,
-
    ].join("\n    ");
-

-
    const injected = html.replace("</head>", `  ${tags}\n</head>`);
-

-
    return new Response(injected, {
-
      status: indexResponse.status,
-
      headers: {
-
        "content-type": "text/html;charset=UTF-8",
-
        "cache-control": "public, max-age=300",
-
      },
-
    });
-
  },
-
};
modified workers/open-graph/index.js
@@ -38,8 +38,7 @@ function ensureInit() {
}

function parseRoute(pathname) {
-
  const path = pathname;
-
  const segments = path.replace(/^\//, "").split("/");
+
  const segments = pathname.replace(/^\//, "").split("/");
  const first = segments[0];

  if (first === "" || first === undefined) return { type: "home" };
@@ -2112,13 +2111,134 @@ async function renderPng(template) {
  return resvg.render().asPng();
}

+
function titleForRoute(route) {
+
  switch (route.type) {
+
    case "home":
+
      return "Radicle Explorer \u00b7 Decentralized Code Collaboration";
+
    case "node":
+
      return `Radicle Seed Node \u00b7 ${route.host}`;
+
    case "user":
+
      return `Radicle User \u00b7 ${shortDid(route.did)} \u00b7 ${route.host}`;
+
    case "repo":
+
      return `Radicle Repo \u00b7 ${route.rid} \u00b7 ${route.host}`;
+
    case "issues":
+
      return `Issues \u00b7 ${route.rid} \u00b7 ${route.host}`;
+
    case "patches":
+
      return `Patches \u00b7 ${route.rid} \u00b7 ${route.host}`;
+
    case "issue":
+
      return `Issue ${route.id.slice(0, 7)} \u00b7 ${route.rid} \u00b7 ${route.host}`;
+
    case "patch":
+
      return `Patch ${route.id.slice(0, 7)} \u00b7 ${route.rid} \u00b7 ${route.host}`;
+
    case "commit":
+
      return `Commit ${route.sha.slice(0, 7)} \u00b7 ${route.rid} \u00b7 ${route.host}`;
+
    case "history":
+
      return `Commit History \u00b7 ${route.rid} \u00b7 ${route.host}`;
+
    default:
+
      return "Radicle Explorer \u00b7 Decentralized Code Collaboration";
+
  }
+
}
+

+
function descriptionForRoute(route) {
+
  const h = route.host;
+
  switch (route.type) {
+
    case "home":
+
      return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network. Sovereign hosting without central servers.";
+
    case "node":
+
      return `Browse repositories hosted on the ${h} Radicle seed node. Explore source code, issues, patches, and contributor activity.`;
+
    case "user":
+
      return `Radicle user profile on ${h}. Browse repositories, patches, and contributions across the peer-to-peer network.`;
+
    case "repo":
+
      return `Radicle repository hosted on ${h}. Browse source code, issues, patches, commit history, and contributor activity.`;
+
    case "issues":
+
      return `Browse issues for this repository on ${h}. View bug reports, feature requests, and ongoing discussions on the Radicle network.`;
+
    case "patches":
+
      return `Browse patches for this repository on ${h}. View proposed changes, code reviews, and merge status on the Radicle network.`;
+
    case "issue":
+
      return `View this issue and its discussion in a Radicle repository on ${h}. Track progress, comments, and resolution.`;
+
    case "patch":
+
      return `View this patch and its review in a Radicle repository on ${h}. Browse revisions, review comments, and merge status.`;
+
    case "commit":
+
      return `View this commit and its diff in a Radicle repository on ${h}. Inspect changed files, author details, and additions.`;
+
    case "history":
+
      return `Browse the commit history of this Radicle repository on ${h}. View commits, contributors, and development activity.`;
+
    default:
+
      return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network.";
+
  }
+
}
+

+
function escapeHtml(str) {
+
  return str
+
    .replaceAll("&", "&amp;")
+
    .replaceAll('"', "&quot;")
+
    .replaceAll("'", "&#39;")
+
    .replaceAll("<", "&lt;")
+
    .replaceAll(">", "&gt;");
+
}
+

+
async function handleOgHtml(request, url, env) {
+
  const cache = caches.default;
+
  const cached = await cache.match(request);
+
  if (cached) return cached;
+

+
  const route = parseRoute(url.pathname);
+
  if (!route) {
+
    return new Response("Not found", { status: 404 });
+
  }
+

+
  const appBase = env.APP_BASE || "https://app.radicle.xyz";
+
  const appHost = new URL(appBase).hostname;
+
  const hostParam = route.type === "home" ? `?host=${appHost}` : "";
+
  const ogImageUrl = escapeHtml(
+
    `https://${url.hostname}/cards${url.pathname}${hostParam}`,
+
  );
+
  const canonicalUrl = escapeHtml(`${appBase}${url.pathname}`);
+
  const title = escapeHtml(titleForRoute(route));
+
  const description = escapeHtml(descriptionForRoute(route));
+

+
  const html = `<!DOCTYPE html>
+
<html>
+
<head>
+
  <meta charset="utf-8" />
+
  <title>${title}</title>
+
  <meta property="og:site_name" content="Radicle Explorer" />
+
  <meta property="og:type" content="website" />
+
  <meta property="og:title" content="${title}" />
+
  <meta property="og:description" content="${description}" />
+
  <meta property="og:url" content="${canonicalUrl}" />
+
  <meta property="og:image" content="${ogImageUrl}" />
+
  <meta property="og:image:width" content="1200" />
+
  <meta property="og:image:height" content="630" />
+
  <meta name="twitter:card" content="summary_large_image" />
+
  <meta name="twitter:site" content="@radicle" />
+
  <meta name="theme-color" content="#1c77ff" />
+
</head>
+
<body></body>
+
</html>`;
+

+
  const response = new Response(html, {
+
    headers: {
+
      "content-type": "text/html;charset=UTF-8",
+
      "cache-control": "public, max-age=7200",
+
    },
+
  });
+

+
  await cache.put(request, response.clone());
+
  return response;
+
}
+

export default {
-
  async fetch(request) {
+
  async fetch(request, env) {
+
    const url = new URL(request.url);
+

+
    if (url.pathname !== "/cards" && !url.pathname.startsWith("/cards/")) {
+
      return handleOgHtml(request, url, env);
+
    }
+

    if (request.method === "HEAD") {
      return new Response(null, {
        headers: {
          "content-type": "image/png",
-
          "cache-control": "public, max-age=120",
+
          "cache-control": "public, max-age=7200",
        },
      });
    }
@@ -2127,8 +2247,8 @@ export default {
    const cached = await cache.match(request);
    if (cached) return cached;

-
    const url = new URL(request.url);
-
    const route = parseRoute(url.pathname);
+
    const pathname = url.pathname.replace(/^\/cards/, "") || "/";
+
    const route = parseRoute(pathname);

    if (!route) {
      return new Response("Not found", { status: 404 });
@@ -2270,7 +2390,7 @@ export default {
      const response = new Response(png, {
        headers: {
          "content-type": "image/png",
-
          "cache-control": "public, max-age=120",
+
          "cache-control": "public, max-age=7200",
        },
      });

modified workers/open-graph/wrangler.toml
@@ -6,6 +6,9 @@ routes = [
  { pattern = "open-graph.radicle.network/*", zone_name = "radicle.network" },
]

+
[vars]
+
APP_BASE = "https://app.radicle.xyz"
+

[[rules]]
type = "CompiledWasm"
globs = ["**/*.wasm"]
modified wrangler.toml
@@ -1,13 +1,7 @@
name = "explorer"
compatibility_date = "2025-04-03"
account_id = "dcd58b4607e42dafa1592d13077a60bc"
-
main = "workers/og-injector.js"

[assets]
directory = "build"
-
binding = "ASSETS"
not_found_handling = "single-page-application"
-
run_worker_first = true
-

-
[vars]
-
OG_IMAGE_BASE = "https://open-graph.radicle.network"