aboutsummaryrefslogtreecommitdiff
path: root/workers
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-21 17:24:17 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-21 17:27:10 +0300
commit2eeea9a0cd8665bbe0e6c80f08603425498757e5 (patch)
tree83f3f572fafbadd1d40593415ccb335d36373eb6 /workers
parent734bf3c09d292012b45b93a4813ccdd0cf26d08a (diff)
feat: add plugin marketplace
Diffstat (limited to 'workers')
-rw-r--r--workers/plugin-marketplace/package.json10
-rw-r--r--workers/plugin-marketplace/r2-cors.json13
-rw-r--r--workers/plugin-marketplace/src/index.test.ts260
-rw-r--r--workers/plugin-marketplace/src/index.ts349
-rw-r--r--workers/plugin-marketplace/wrangler.toml11
5 files changed, 643 insertions, 0 deletions
diff --git a/workers/plugin-marketplace/package.json b/workers/plugin-marketplace/package.json
new file mode 100644
index 00000000..d3ab29d1
--- /dev/null
+++ b/workers/plugin-marketplace/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "herdr-plugin-marketplace-worker",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "bunx wrangler dev",
+ "deploy": "bunx wrangler deploy",
+ "test": "bun test"
+ }
+}
diff --git a/workers/plugin-marketplace/r2-cors.json b/workers/plugin-marketplace/r2-cors.json
new file mode 100644
index 00000000..a8c8d99f
--- /dev/null
+++ b/workers/plugin-marketplace/r2-cors.json
@@ -0,0 +1,13 @@
+{
+ "rules": [
+ {
+ "allowed": {
+ "origins": ["https://herdr.dev"],
+ "methods": ["GET", "HEAD"],
+ "headers": ["*"]
+ },
+ "exposeHeaders": ["Content-Type", "Cache-Control", "ETag"],
+ "maxAgeSeconds": 3600
+ }
+ ]
+}
diff --git a/workers/plugin-marketplace/src/index.test.ts b/workers/plugin-marketplace/src/index.test.ts
new file mode 100644
index 00000000..c09d60eb
--- /dev/null
+++ b/workers/plugin-marketplace/src/index.test.ts
@@ -0,0 +1,260 @@
+import { describe, expect, test } from "bun:test";
+import worker, { normalizeRepositories, refreshPlugins, type Env } from "./index";
+
+class MemoryR2 {
+ objects = new Map<string, { value: string; options: unknown }>();
+
+ async put(key: string, value: string, options?: unknown): Promise<void> {
+ this.objects.set(key, { value, options });
+ }
+}
+
+function repo(overrides: Record<string, unknown> = {}): Record<string, unknown> {
+ return {
+ id: 1,
+ full_name: "ogulcancelik/herdr-plugin-example",
+ owner: { login: "ogulcancelik" },
+ name: "herdr-plugin-example",
+ description: "Example plugin",
+ html_url: "https://github.com/ogulcancelik/herdr-plugin-example",
+ stargazers_count: 5,
+ forks_count: 1,
+ open_issues_count: 0,
+ language: "TypeScript",
+ topics: ["herdr-plugin"],
+ created_at: "2026-06-01T00:00:00Z",
+ updated_at: "2026-06-02T00:00:00Z",
+ pushed_at: "2026-06-03T00:00:00Z",
+ archived: false,
+ fork: false,
+ disabled: false,
+ private: false,
+ visibility: "public",
+ ...overrides,
+ };
+}
+
+function env(bucket = new MemoryR2()): Env {
+ return {
+ PLUGIN_MARKETPLACE_BUCKET: bucket,
+ GITHUB_TOKEN: "token",
+ };
+}
+
+describe("normalizeRepositories", () => {
+ test("normalizes repository fields into the public snapshot schema", () => {
+ const [plugin] = normalizeRepositories([repo()]);
+
+ expect(plugin).toEqual({
+ id: 1,
+ fullName: "ogulcancelik/herdr-plugin-example",
+ owner: "ogulcancelik",
+ name: "herdr-plugin-example",
+ description: "Example plugin",
+ url: "https://github.com/ogulcancelik/herdr-plugin-example",
+ stars: 5,
+ forks: 1,
+ openIssues: 0,
+ language: "TypeScript",
+ topics: ["herdr-plugin"],
+ createdAt: "2026-06-01T00:00:00Z",
+ updatedAt: "2026-06-02T00:00:00Z",
+ pushedAt: "2026-06-03T00:00:00Z",
+ });
+ });
+
+ test("sorts by stars, pushed date, and full name", () => {
+ const plugins = normalizeRepositories([
+ repo({
+ id: 1,
+ full_name: "z/z",
+ owner: { login: "z" },
+ name: "z",
+ html_url: "https://github.com/z/z",
+ stargazers_count: 3,
+ pushed_at: "2026-06-01T00:00:00Z",
+ }),
+ repo({
+ id: 2,
+ full_name: "a/a",
+ owner: { login: "a" },
+ name: "a",
+ html_url: "https://github.com/a/a",
+ stargazers_count: 3,
+ pushed_at: "2026-06-02T00:00:00Z",
+ }),
+ repo({
+ id: 3,
+ full_name: "m/m",
+ owner: { login: "m" },
+ name: "m",
+ html_url: "https://github.com/m/m",
+ stargazers_count: 10,
+ }),
+ ]);
+
+ expect(plugins.map((plugin) => plugin.fullName)).toEqual(["m/m", "a/a", "z/z"]);
+ });
+
+ test("drops unsafe urls, archived repositories, forks, disabled repositories, and private repositories", () => {
+ const plugins = normalizeRepositories([
+ repo({ html_url: "https://example.com/ogulcancelik/herdr-plugin-example" }),
+ repo({ archived: true }),
+ repo({ fork: true }),
+ repo({ disabled: true }),
+ repo({ private: true }),
+ repo({ visibility: "private" }),
+ repo({ id: 5 }),
+ ]);
+
+ expect(plugins.map((plugin) => plugin.id)).toEqual([5]);
+ });
+
+ test("uses safe defaults for missing nullable fields", () => {
+ const [plugin] = normalizeRepositories([
+ repo({
+ id: undefined,
+ description: undefined,
+ stargazers_count: undefined,
+ forks_count: undefined,
+ open_issues_count: undefined,
+ language: undefined,
+ topics: undefined,
+ created_at: "not a date",
+ updated_at: undefined,
+ pushed_at: undefined,
+ }),
+ ]);
+
+ expect(plugin.id).toBe(0);
+ expect(plugin.description).toBeNull();
+ expect(plugin.stars).toBe(0);
+ expect(plugin.forks).toBe(0);
+ expect(plugin.openIssues).toBe(0);
+ expect(plugin.language).toBeNull();
+ expect(plugin.topics).toEqual([]);
+ expect(plugin.createdAt).toBeNull();
+ expect(plugin.updatedAt).toBeNull();
+ expect(plugin.pushedAt).toBeNull();
+ });
+});
+
+describe("refreshPlugins", () => {
+ test("fetches pages and writes a sorted snapshot to R2 with cache metadata", async () => {
+ const calls: string[] = [];
+ const fetch = async (input: RequestInfo | URL): Promise<Response> => {
+ const url = new URL(input.toString());
+ calls.push(url.searchParams.get("page") ?? "");
+ const page = url.searchParams.get("page");
+ const item =
+ page === "1"
+ ? repo({ id: 1, full_name: "b/b", owner: { login: "b" }, name: "b", html_url: "https://github.com/b/b" })
+ : repo({
+ id: 2,
+ full_name: "a/a",
+ owner: { login: "a" },
+ name: "a",
+ html_url: "https://github.com/a/a",
+ stargazers_count: 9,
+ });
+ return Response.json({ total_count: 2, items: [item] });
+ };
+ const bucket = new MemoryR2();
+
+ const result = await refreshPlugins(env(bucket), {
+ fetch,
+ now: new Date("2026-06-20T12:00:00.000Z"),
+ logger: { error() {} },
+ });
+
+ expect(result.ok).toBe(true);
+ expect(calls).toEqual(["1", "2"]);
+ const object = bucket.objects.get("plugins/index.json");
+ expect(object?.options).toEqual({
+ httpMetadata: {
+ contentType: "application/json; charset=utf-8",
+ cacheControl: "public, max-age=300, s-maxage=1800, stale-while-revalidate=3600",
+ },
+ });
+ const snapshot = JSON.parse(object?.value ?? "");
+ expect(snapshot.generatedAt).toBe("2026-06-20T12:00:00.000Z");
+ expect(snapshot.source).toMatchObject({
+ provider: "github",
+ query: "topic:herdr-plugin is:public",
+ totalCount: 2,
+ collectedCount: 2,
+ truncated: false,
+ });
+ expect(snapshot.plugins.map((plugin: { fullName: string }) => plugin.fullName)).toEqual([
+ "a/a",
+ "b/b",
+ ]);
+ });
+
+ test("marks snapshots truncated at the GitHub search cap", async () => {
+ const fetch = async (): Promise<Response> => {
+ const items = Array.from({ length: 100 }, (_, index) =>
+ repo({
+ id: index,
+ full_name: `owner/repo-${index}`,
+ owner: { login: "owner" },
+ name: `repo-${index}`,
+ html_url: `https://github.com/owner/repo-${index}`,
+ }),
+ );
+ return Response.json({ total_count: 1200, items });
+ };
+
+ const result = await refreshPlugins(env(), {
+ fetch,
+ now: new Date("2026-06-20T12:00:00.000Z"),
+ logger: { error() {} },
+ });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.snapshot.source.collectedCount).toBe(1000);
+ expect(result.snapshot.source.truncated).toBe(true);
+ expect(result.snapshot.source.warnings?.[0]).toContain("1200");
+ });
+
+ for (const { name, fetch } of [
+ {
+ name: "GitHub failure",
+ fetch: async (): Promise<Response> => new Response("rate limited", { status: 429 }),
+ },
+ {
+ name: "no listable repositories",
+ fetch: async (): Promise<Response> => Response.json({ total_count: 0, items: [] }),
+ },
+ {
+ name: "incomplete search results",
+ fetch: async (): Promise<Response> =>
+ Response.json({ total_count: 1, incomplete_results: true, items: [repo()] }),
+ },
+ ]) {
+ test(`does not overwrite the R2 snapshot on ${name}`, async () => {
+ const bucket = new MemoryR2();
+ await bucket.put("plugins/index.json", '{"schemaVersion":1,"plugins":[{"id":1}]}');
+
+ const result = await refreshPlugins(env(bucket), {
+ fetch,
+ logger: { error() {} },
+ });
+
+ expect(result.ok).toBe(false);
+ expect(bucket.objects.get("plugins/index.json")?.value).toBe(
+ '{"schemaVersion":1,"plugins":[{"id":1}]}',
+ );
+ });
+ }
+});
+
+describe("fetch handler", () => {
+ test("does not expose a public Worker API", async () => {
+ const response = await worker.fetch(new Request("https://herdr.dev/api/plugins"), env());
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ });
+});
diff --git a/workers/plugin-marketplace/src/index.ts b/workers/plugin-marketplace/src/index.ts
new file mode 100644
index 00000000..b85abdb8
--- /dev/null
+++ b/workers/plugin-marketplace/src/index.ts
@@ -0,0 +1,349 @@
+const SNAPSHOT_KEY = "plugins/index.json";
+const SNAPSHOT_CACHE_CONTROL = "public, max-age=300, s-maxage=1800, stale-while-revalidate=3600";
+const GITHUB_QUERY = "topic:herdr-plugin is:public";
+const GITHUB_API_VERSION = "2022-11-28";
+const GITHUB_SEARCH_URL = "https://api.github.com/search/repositories";
+const PER_PAGE = 100;
+const MAX_REPOS = 1000;
+const REQUEST_TIMEOUT_MS = 10_000;
+
+type R2Bucket = {
+ put(
+ key: string,
+ value: string,
+ options?: {
+ httpMetadata?: {
+ contentType?: string;
+ cacheControl?: string;
+ };
+ },
+ ): Promise<unknown>;
+};
+
+type ExecutionContext = {
+ waitUntil(promise: Promise<unknown>): void;
+};
+
+type ScheduledController = unknown;
+
+export type Env = {
+ PLUGIN_MARKETPLACE_BUCKET: R2Bucket;
+ GITHUB_TOKEN?: string;
+};
+
+type FetchLike = typeof fetch;
+
+type RefreshOptions = {
+ fetch?: FetchLike;
+ now?: Date;
+ logger?: Pick<Console, "error">;
+};
+
+type GitHubRepository = Record<string, unknown>;
+
+export type PluginListing = {
+ id: number;
+ fullName: string;
+ owner: string;
+ name: string;
+ description: string | null;
+ url: string;
+ stars: number;
+ forks: number;
+ openIssues: number;
+ language: string | null;
+ topics: string[];
+ createdAt: string | null;
+ updatedAt: string | null;
+ pushedAt: string | null;
+};
+
+export type PluginSnapshot = {
+ schemaVersion: 1;
+ generatedAt: string;
+ source: {
+ provider: "github";
+ query: string;
+ totalCount: number;
+ collectedCount: number;
+ truncated: boolean;
+ warnings?: string[];
+ };
+ plugins: PluginListing[];
+};
+
+export type RefreshResult =
+ | { ok: true; snapshot: PluginSnapshot }
+ | { ok: false; error: string };
+
+export default {
+ fetch(): Response {
+ return jsonResponse({ error: "Not found" }, 404, "no-store");
+ },
+
+ scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext): void {
+ ctx.waitUntil(refreshPlugins(env));
+ },
+};
+
+export async function refreshPlugins(
+ env: Env,
+ options: RefreshOptions = {},
+): Promise<RefreshResult> {
+ const logger = options.logger ?? console;
+ try {
+ const token = env.GITHUB_TOKEN?.trim();
+ if (!token) {
+ throw new Error("GITHUB_TOKEN is not configured");
+ }
+
+ const fetchFn = options.fetch ?? fetch;
+ const result = await fetchGitHubRepositories(fetchFn, token);
+ const plugins = normalizeRepositories(result.repositories);
+ if (plugins.length === 0) {
+ throw new Error("GitHub returned no listable plugin repositories");
+ }
+
+ const snapshot: PluginSnapshot = {
+ schemaVersion: 1,
+ generatedAt: (options.now ?? new Date()).toISOString(),
+ source: {
+ provider: "github",
+ query: GITHUB_QUERY,
+ totalCount: result.totalCount,
+ collectedCount: result.repositories.length,
+ truncated: result.truncated,
+ },
+ plugins,
+ };
+
+ if (result.truncated) {
+ snapshot.source.warnings = [
+ `GitHub returned ${result.totalCount} results; only the first ${result.repositories.length} were collected.`,
+ ];
+ }
+
+ await env.PLUGIN_MARKETPLACE_BUCKET.put(SNAPSHOT_KEY, JSON.stringify(snapshot), {
+ httpMetadata: {
+ contentType: "application/json; charset=utf-8",
+ cacheControl: SNAPSHOT_CACHE_CONTROL,
+ },
+ });
+ return { ok: true, snapshot };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "unknown refresh error";
+ logger.error(`plugin marketplace refresh failed: ${message}`);
+ return { ok: false, error: message };
+ }
+}
+
+async function fetchGitHubRepositories(
+ fetchFn: FetchLike,
+ token: string,
+ timeoutMs = REQUEST_TIMEOUT_MS,
+): Promise<{ repositories: GitHubRepository[]; totalCount: number; truncated: boolean }> {
+ const repositories: GitHubRepository[] = [];
+ let totalCount = 0;
+
+ for (let page = 1; repositories.length < MAX_REPOS; page += 1) {
+ const url = new URL(GITHUB_SEARCH_URL);
+ url.searchParams.set("q", GITHUB_QUERY);
+ url.searchParams.set("per_page", String(PER_PAGE));
+ url.searchParams.set("page", String(page));
+ url.searchParams.set("sort", "stars");
+ url.searchParams.set("order", "desc");
+
+ const response = await fetchWithTimeout(
+ fetchFn,
+ url,
+ {
+ headers: {
+ Accept: "application/vnd.github+json",
+ Authorization: `Bearer ${token}`,
+ "User-Agent": "herdr-plugin-marketplace",
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
+ },
+ },
+ timeoutMs,
+ );
+
+ if (!response.ok) {
+ throw new Error(`GitHub search failed with status ${response.status}`);
+ }
+
+ const body = await response.json();
+ if (!isObject(body) || typeof body.total_count !== "number" || !Array.isArray(body.items)) {
+ throw new Error("GitHub search returned malformed JSON");
+ }
+ if (body.incomplete_results === true) {
+ throw new Error("GitHub search returned incomplete results");
+ }
+
+ totalCount = body.total_count;
+ repositories.push(...body.items.slice(0, MAX_REPOS - repositories.length));
+
+ if (repositories.length >= totalCount || body.items.length === 0) {
+ break;
+ }
+ }
+
+ return {
+ repositories,
+ totalCount,
+ truncated: totalCount > repositories.length,
+ };
+}
+
+async function fetchWithTimeout(
+ fetchFn: FetchLike,
+ url: URL,
+ init: RequestInit,
+ timeoutMs: number,
+): Promise<Response> {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ return await fetchFn(url, { ...init, signal: controller.signal });
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+export function normalizeRepositories(repositories: GitHubRepository[]): PluginListing[] {
+ return repositories
+ .map(normalizeRepository)
+ .filter((plugin): plugin is PluginListing => plugin !== null)
+ .sort(comparePlugins);
+}
+
+function normalizeRepository(repo: GitHubRepository): PluginListing | null {
+ if (
+ readBoolean(repo.disabled) ||
+ readBoolean(repo.archived) ||
+ readBoolean(repo.fork) ||
+ readBoolean(repo.private) ||
+ readString(repo.visibility) === "private"
+ ) {
+ return null;
+ }
+
+ const fullNameParts = splitFullName(readString(repo.full_name));
+ const ownerObject = isObject(repo.owner) ? repo.owner : {};
+ const owner = firstString(readString(ownerObject.login), fullNameParts.owner);
+ const name = firstString(readString(repo.name), fullNameParts.name);
+ if (!owner || !name) {
+ return null;
+ }
+
+ const fullName = readString(repo.full_name) ?? `${owner}/${name}`;
+ const url = readString(repo.html_url);
+ if (!url || !isValidGitHubRepoUrl(url, owner, name)) {
+ return null;
+ }
+
+ return {
+ id: readInteger(repo.id) ?? 0,
+ fullName,
+ owner,
+ name,
+ description: readNullableString(repo.description),
+ url,
+ stars: readNonNegativeInteger(repo.stargazers_count),
+ forks: readNonNegativeInteger(repo.forks_count),
+ openIssues: readNonNegativeInteger(repo.open_issues_count),
+ language: readNullableString(repo.language),
+ topics: readStringArray(repo.topics),
+ createdAt: readIsoString(repo.created_at),
+ updatedAt: readIsoString(repo.updated_at),
+ pushedAt: readIsoString(repo.pushed_at),
+ };
+}
+
+function comparePlugins(a: PluginListing, b: PluginListing): number {
+ return (
+ b.stars - a.stars ||
+ dateMs(b.pushedAt) - dateMs(a.pushedAt) ||
+ a.fullName.localeCompare(b.fullName)
+ );
+}
+
+function jsonResponse(body: unknown, status: number, cacheControl: string): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": cacheControl,
+ },
+ });
+}
+
+function isValidGitHubRepoUrl(url: string, owner: string, name: string): boolean {
+ try {
+ const parsed = new URL(url);
+ const segments = parsed.pathname.split("/").filter(Boolean);
+ return (
+ parsed.protocol === "https:" &&
+ parsed.hostname === "github.com" &&
+ segments.length === 2 &&
+ segments[0].toLowerCase() === owner.toLowerCase() &&
+ segments[1].toLowerCase() === name.toLowerCase()
+ );
+ } catch {
+ return false;
+ }
+}
+
+function splitFullName(fullName: string | null): { owner: string | null; name: string | null } {
+ if (!fullName) {
+ return { owner: null, name: null };
+ }
+ const [owner, name, extra] = fullName.split("/");
+ if (!owner || !name || extra) {
+ return { owner: null, name: null };
+ }
+ return { owner, name };
+}
+
+function firstString(...values: Array<string | null>): string | null {
+ return values.find((value) => value !== null) ?? null;
+}
+
+function readString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+function readNullableString(value: unknown): string | null {
+ return typeof value === "string" ? value : null;
+}
+
+function readStringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+}
+
+function readInteger(value: unknown): number | null {
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
+}
+
+function readNonNegativeInteger(value: unknown): number {
+ const integer = readInteger(value);
+ return integer !== null && integer >= 0 ? integer : 0;
+}
+
+function readBoolean(value: unknown): boolean {
+ return value === true;
+}
+
+function readIsoString(value: unknown): string | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+ return Number.isNaN(Date.parse(value)) ? null : value;
+}
+
+function dateMs(value: string | null): number {
+ return value ? Date.parse(value) || 0 : 0;
+}
+
+function isObject(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null;
+}
diff --git a/workers/plugin-marketplace/wrangler.toml b/workers/plugin-marketplace/wrangler.toml
new file mode 100644
index 00000000..55b2e069
--- /dev/null
+++ b/workers/plugin-marketplace/wrangler.toml
@@ -0,0 +1,11 @@
+name = "herdr-plugin-marketplace"
+main = "src/index.ts"
+compatibility_date = "2026-06-21"
+workers_dev = false
+
+[[r2_buckets]]
+binding = "PLUGIN_MARKETPLACE_BUCKET"
+bucket_name = "herdr-plugin-marketplace"
+
+[triggers]
+crons = ["*/30 * * * *"]