diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-21 17:24:17 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-21 17:27:10 +0300 |
| commit | 2eeea9a0cd8665bbe0e6c80f08603425498757e5 (patch) | |
| tree | 83f3f572fafbadd1d40593415ccb335d36373eb6 /workers/plugin-marketplace/src | |
| parent | 734bf3c09d292012b45b93a4813ccdd0cf26d08a (diff) | |
feat: add plugin marketplace
Diffstat (limited to 'workers/plugin-marketplace/src')
| -rw-r--r-- | workers/plugin-marketplace/src/index.test.ts | 260 | ||||
| -rw-r--r-- | workers/plugin-marketplace/src/index.ts | 349 |
2 files changed, 609 insertions, 0 deletions
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; +} |
