Skip to content

Commit 5cfeea6

Browse files
committed
Add feed discovery plugin
1 parent e47616b commit 5cfeea6

37 files changed

Lines changed: 2432 additions & 5 deletions

apps/commandboard-api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@logicsrc/plugin-core": "file:../../packages/plugin-core",
1616
"@logicsrc/plugin-c0mpute": "file:../../plugins/c0mpute",
1717
"@logicsrc/plugin-coinpay": "file:../../plugins/coinpay",
18+
"@logicsrc/plugin-feed-discovery": "file:../../plugins/feed-discovery",
1819
"@logicsrc/plugin-sh1pt": "file:../../plugins/sh1pt",
1920
"@logicsrc/plugin-ugig": "file:../../plugins/ugig",
2021
"@logicsrc/validators": "file:../../packages/validators"

apps/commandboard-api/src/contract.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe("CommandBoard API contracts", () => {
4747
};
4848

4949
expect(response.status).toBe(200);
50-
expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt", "c0mpute"]);
50+
expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt", "c0mpute", "feed-discovery"]);
5151
expect(body.plugins.find((plugin) => plugin.id === "sh1pt")).toMatchObject({
5252
enabled: true,
5353
capabilities: expect.arrayContaining(["projects.sync", "actions.publish", "deployments.status"])
@@ -58,6 +58,28 @@ describe("CommandBoard API contracts", () => {
5858
});
5959
expect(body.capabilities["actions.publish"]).toEqual(["sh1pt"]);
6060
expect(body.capabilities["compute.jobs.dispatch"]).toEqual(["c0mpute"]);
61+
expect(body.capabilities["feeds.discover"]).toEqual(["feed-discovery"]);
62+
});
63+
64+
it("exposes feed discovery plugin endpoints", async () => {
65+
const providersResponse = await fetch(`${baseUrl}/api/feeds/providers`);
66+
const providersBody = await providersResponse.json() as { providers: Array<{ id: string; enabledByDefault: boolean }> };
67+
const discoverResponse = await fetch(`${baseUrl}/api/feeds/discover?q=microsaas&providers=manual-curated&includeUnvalidated=true`);
68+
const discoverBody = await discoverResponse.json() as { query: string; results: Array<{ feedUrl: string; provider: string }> };
69+
const rssResponse = await fetch(`${baseUrl}/rss/discover/microsaas.xml?providers=manual-curated&includeUnvalidated=true`);
70+
const rssBody = await rssResponse.text();
71+
const opmlResponse = await fetch(`${baseUrl}/opml/discover/microsaas.xml?providers=manual-curated&includeUnvalidated=true`);
72+
const opmlBody = await opmlResponse.text();
73+
74+
expect(providersResponse.status).toBe(200);
75+
expect(providersBody.providers.map((provider) => provider.id)).toContain("manual-curated");
76+
expect(discoverResponse.status).toBe(200);
77+
expect(discoverBody.query).toBe("microsaas");
78+
expect(discoverBody.results[0]).toMatchObject({ provider: "manual-curated" });
79+
expect(rssResponse.status).toBe(200);
80+
expect(rssBody).toContain("<rss");
81+
expect(opmlResponse.status).toBe(200);
82+
expect(opmlBody).toContain("<opml");
6183
});
6284

6385
it("exposes sh1pt project and action contracts", async () => {

apps/commandboard-api/src/index.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { pathToFileURL } from "node:url";
33
import { createPluginRegistry } from "@logicsrc/plugin-core";
44
import { c0mputePlugin } from "@logicsrc/plugin-c0mpute";
55
import { coinPayPlugin } from "@logicsrc/plugin-coinpay";
6+
import { discoverFeeds, feedDiscoveryPlugin, listFeedProviders, renderAtom, renderJsonFeed, renderOpml, renderRss, type FeedKind } from "@logicsrc/plugin-feed-discovery";
67
import { sh1ptPlugin } from "@logicsrc/plugin-sh1pt";
78
import { uGigPlugin } from "@logicsrc/plugin-ugig";
89
import { schemas, validate } from "@logicsrc/validators";
910

10-
const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin, c0mputePlugin]);
11+
const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin, c0mputePlugin, feedDiscoveryPlugin]);
1112

1213
const boards = [
1314
{ path: "/general", title: "General", description: "CommandBoard.run general discussion." },
@@ -69,7 +70,7 @@ async function route(request: IncomingMessage, response: ServerResponse) {
6970
json(response, 200, {
7071
ok: true,
7172
service: "commandboard-api",
72-
endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas"]
73+
endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas", "/api/feeds/discover", "/api/feeds/providers", "/rss/discover/:keyword.xml", "/opml/discover/:keyword.xml", "/atom/discover/:keyword.xml", "/json-feed/discover/:keyword.json"]
7374
});
7475
return;
7576
}
@@ -113,6 +114,53 @@ async function route(request: IncomingMessage, response: ServerResponse) {
113114
return;
114115
}
115116

117+
if (request.method === "GET" && (url.pathname === "/api/feeds/providers" || url.pathname === "/api/rss/providers")) {
118+
json(response, 200, { providers: listFeedProviders() });
119+
return;
120+
}
121+
122+
if (request.method === "GET" && (url.pathname === "/api/feeds/discover" || url.pathname === "/api/rss/discover")) {
123+
const query = url.searchParams.get("q");
124+
if (!query) {
125+
json(response, 422, { error: "Expected q query parameter" });
126+
return;
127+
}
128+
const result = await discoverFeeds({
129+
q: query,
130+
type: (url.searchParams.get("type") ?? "all") as FeedKind | "all",
131+
limit: numberParam(url.searchParams.get("limit")),
132+
providers: listParam(url.searchParams.get("providers")),
133+
includeUnvalidated: url.searchParams.get("includeUnvalidated") === "true"
134+
});
135+
json(response, 200, result);
136+
return;
137+
}
138+
139+
const formattedDiscover = matchFormattedDiscoverPath(url.pathname);
140+
if (request.method === "GET" && formattedDiscover) {
141+
const result = await discoverFeeds({
142+
q: formattedDiscover.keyword,
143+
type: "all",
144+
limit: numberParam(url.searchParams.get("limit")),
145+
providers: listParam(url.searchParams.get("providers")),
146+
includeUnvalidated: url.searchParams.get("includeUnvalidated") === "true"
147+
});
148+
if (formattedDiscover.format === "rss") {
149+
text(response, 200, "application/rss+xml; charset=utf-8", renderRss(result));
150+
return;
151+
}
152+
if (formattedDiscover.format === "opml") {
153+
text(response, 200, "text/x-opml; charset=utf-8", renderOpml(result));
154+
return;
155+
}
156+
if (formattedDiscover.format === "atom") {
157+
text(response, 200, "application/atom+xml; charset=utf-8", renderAtom(result));
158+
return;
159+
}
160+
text(response, 200, "application/feed+json; charset=utf-8", renderJsonFeed(result));
161+
return;
162+
}
163+
116164
if (request.method === "GET" && url.pathname === "/api/plugins/sh1pt/projects") {
117165
json(response, 200, { projects: sh1ptProjects });
118166
return;
@@ -194,6 +242,11 @@ function json(response: ServerResponse, status: number, data: unknown) {
194242
response.end(JSON.stringify(data, null, 2));
195243
}
196244

245+
function text(response: ServerResponse, status: number, contentType: string, body: string) {
246+
response.writeHead(status, { "content-type": contentType });
247+
response.end(body);
248+
}
249+
197250
async function readJson(request: IncomingMessage) {
198251
const chunks: Buffer[] = [];
199252
for await (const chunk of request) {
@@ -207,6 +260,41 @@ function isRecord(value: unknown): value is Record<string, unknown> {
207260
return typeof value === "object" && value !== null && !Array.isArray(value);
208261
}
209262

263+
function numberParam(value: string | null) {
264+
if (!value) {
265+
return undefined;
266+
}
267+
const parsed = Number(value);
268+
return Number.isFinite(parsed) ? parsed : undefined;
269+
}
270+
271+
function listParam(value: string | null) {
272+
return value
273+
?.split(",")
274+
.map((entry) => entry.trim())
275+
.filter(Boolean);
276+
}
277+
278+
function matchFormattedDiscoverPath(pathname: string) {
279+
const rss = /^\/rss\/discover\/(.+)\.xml$/.exec(pathname);
280+
if (rss) {
281+
return { format: "rss" as const, keyword: decodeURIComponent(rss[1]) };
282+
}
283+
const opml = /^\/opml\/discover\/(.+)\.xml$/.exec(pathname) ?? /^\/rss\/discover\/(.+)\.opml$/.exec(pathname);
284+
if (opml) {
285+
return { format: "opml" as const, keyword: decodeURIComponent(opml[1]) };
286+
}
287+
const atom = /^\/atom\/discover\/(.+)\.xml$/.exec(pathname);
288+
if (atom) {
289+
return { format: "atom" as const, keyword: decodeURIComponent(atom[1]) };
290+
}
291+
const jsonFeed = /^\/json-feed\/discover\/(.+)\.json$/.exec(pathname);
292+
if (jsonFeed) {
293+
return { format: "json-feed" as const, keyword: decodeURIComponent(jsonFeed[1]) };
294+
}
295+
return undefined;
296+
}
297+
210298
export function startCommandBoardServer(port = Number(process.env.PORT ?? 4010)) {
211299
const server = createCommandBoardServer();
212300
server.listen(port, () => {

0 commit comments

Comments
 (0)