@aag1999/plugin-github-trending-backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://backstage.io/schema/config-v1",
3
+ "type": "object",
4
+ "properties": {
5
+ "githubTrending": {
6
+ "type": "object",
7
+ "description": "GitHub Trending ticker backend",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "baseUrl": {
11
+ "type": "string",
12
+ "description": "GitHub website origin that hosts /trending. Default https://github.com. GHES has no Trending page."
13
+ },
14
+ "since": {
15
+ "type": "string",
16
+ "description": "Default window: daily, weekly, or monthly",
17
+ "enum": ["daily", "weekly", "monthly"]
18
+ },
19
+ "language": {
20
+ "type": "string",
21
+ "description": "Optional programming-language slug, e.g. typescript"
22
+ },
23
+ "spokenLanguageCode": {
24
+ "type": "string",
25
+ "description": "Optional spoken-language code from github.com/trending, e.g. en"
26
+ },
27
+ "cacheTtlMinutes": {
28
+ "type": "number",
29
+ "description": "In-memory cache TTL in minutes. Default 45."
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ class MemoryTtlCache {
4
+ store = /* @__PURE__ */ new Map();
5
+ get(key) {
6
+ const hit = this.store.get(key);
7
+ if (!hit) {
8
+ return void 0;
9
+ }
10
+ if (Date.now() > hit.expiresAt) {
11
+ this.store.delete(key);
12
+ return void 0;
13
+ }
14
+ return hit.value;
15
+ }
16
+ set(key, value, ttlMs) {
17
+ this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
18
+ }
19
+ }
20
+
21
+ exports.MemoryTtlCache = MemoryTtlCache;
22
+ //# sourceMappingURL=cache.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.cjs.js","sources":["../src/cache.ts"],"sourcesContent":["/*\n * Copyright 2026 AAG1999 / contributors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\ntype Entry<T> = { value: T; expiresAt: number };\n\n/** Process-local TTL cache so many portal users share one GitHub fetch. */\nexport class MemoryTtlCache<T> {\n private readonly store = new Map<string, Entry<T>>();\n\n get(key: string): T | undefined {\n const hit = this.store.get(key);\n if (!hit) {\n return undefined;\n }\n if (Date.now() > hit.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n return hit.value;\n }\n\n set(key: string, value: T, ttlMs: number): void {\n this.store.set(key, { value, expiresAt: Date.now() + ttlMs });\n }\n}\n"],"names":[],"mappings":";;AAaO,MAAM,cAAA,CAAkB;AAAA,EACZ,KAAA,uBAAY,GAAA,EAAsB;AAAA,EAEnD,IAAI,GAAA,EAA4B;AAC9B,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,GAAG,CAAA;AAC9B,IAAA,IAAI,CAAC,GAAA,EAAK;AACR,MAAA,OAAO,MAAA;AAAA,IACT;AACA,IAAA,IAAI,IAAA,CAAK,GAAA,EAAI,GAAI,GAAA,CAAI,SAAA,EAAW;AAC9B,MAAA,IAAA,CAAK,KAAA,CAAM,OAAO,GAAG,CAAA;AACrB,MAAA,OAAO,MAAA;AAAA,IACT;AACA,IAAA,OAAO,GAAA,CAAI,KAAA;AAAA,EACb;AAAA,EAEA,GAAA,CAAI,GAAA,EAAa,KAAA,EAAU,KAAA,EAAqB;AAC9C,IAAA,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,GAAA,EAAK,EAAE,KAAA,EAAO,WAAW,IAAA,CAAK,GAAA,EAAI,GAAI,KAAA,EAAO,CAAA;AAAA,EAC9D;AACF;;"}
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var plugin = require('./plugin.cjs.js');
6
+ var parser = require('./parser.cjs.js');
7
+
8
+
9
+
10
+ exports.default = plugin.githubTrendingPlugin;
11
+ exports.githubTrendingPlugin = plugin.githubTrendingPlugin;
12
+ exports.parseTrendingHtml = parser.parseTrendingHtml;
13
+ //# sourceMappingURL=index.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;"}
@@ -0,0 +1,16 @@
1
+ import * as _backstage_backend_plugin_api from '@backstage/backend-plugin-api';
2
+ import { TrendingRepository } from '@aag1999/plugin-github-trending-common';
3
+
4
+ /**
5
+ * Fetches and caches github.com/trending for the home ticker.
6
+ * @public
7
+ */
8
+ declare const githubTrendingPlugin: _backstage_backend_plugin_api.BackendFeature;
9
+
10
+ /**
11
+ * Parse github.com/trending HTML into structured rows.
12
+ * Markup is treated as unstable; keep fixtures in lockstep with GitHub.
13
+ */
14
+ declare function parseTrendingHtml(html: string, siteOrigin?: string): TrendingRepository[];
15
+
16
+ export { githubTrendingPlugin as default, githubTrendingPlugin, parseTrendingHtml };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ const ARTICLE_RE = /<article\b[^>]*\bBox-row\b[^>]*>([\s\S]*?)<\/article>/gi;
4
+ const HREF_RE = /<h2\b[^>]*>[\s\S]*?href="\/([^"/]+)\/([^"/?#]+)"/i;
5
+ const DESC_RE = /<p\b[^>]*col-9[^>]*>([\s\S]*?)<\/p>/i;
6
+ const LANG_RE = /itemprop="programmingLanguage">\s*([^<]+)\s*</i;
7
+ const STARS_RE = /href="\/[^"]+\/stargazers"[^>]*>[\s\S]*?([\d,]+)\s*</i;
8
+ const FORKS_RE = /href="\/[^"]+\/(?:network\/members|forks)"[^>]*>[\s\S]*?([\d,]+)\s*</i;
9
+ const PERIOD_RE = /([\d,]+)\s+stars\s+(today|this week|this month)/i;
10
+ function parseCount(raw) {
11
+ if (!raw) {
12
+ return 0;
13
+ }
14
+ const n = Number(raw.replace(/,/g, "").trim());
15
+ return Number.isFinite(n) ? n : 0;
16
+ }
17
+ function decodeEntities(text) {
18
+ return text.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
19
+ }
20
+ function parseTrendingHtml(html, siteOrigin = "https://github.com") {
21
+ const origin = siteOrigin.replace(/\/$/, "");
22
+ const rows = [];
23
+ const articleRe = new RegExp(ARTICLE_RE.source, ARTICLE_RE.flags);
24
+ let rank = 0;
25
+ for (; ; ) {
26
+ const match = articleRe.exec(html);
27
+ if (!match) {
28
+ break;
29
+ }
30
+ const body = match[1];
31
+ const href = body.match(HREF_RE);
32
+ if (!href) {
33
+ continue;
34
+ }
35
+ rank += 1;
36
+ const owner = href[1];
37
+ const name = href[2];
38
+ const descMatch = body.match(DESC_RE);
39
+ const langMatch = body.match(LANG_RE);
40
+ const periodMatch = body.match(PERIOD_RE);
41
+ rows.push({
42
+ rank,
43
+ owner,
44
+ name,
45
+ url: `${origin}/${owner}/${name}`,
46
+ description: descMatch ? decodeEntities(descMatch[1]) : void 0,
47
+ language: langMatch ? decodeEntities(langMatch[1]) : void 0,
48
+ stars: parseCount(body.match(STARS_RE)?.[1]),
49
+ forks: parseCount(body.match(FORKS_RE)?.[1]),
50
+ starsInPeriod: parseCount(periodMatch?.[1]),
51
+ periodLabel: periodMatch?.[2] ?? "today"
52
+ });
53
+ }
54
+ return rows;
55
+ }
56
+
57
+ exports.parseTrendingHtml = parseTrendingHtml;
58
+ //# sourceMappingURL=parser.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parser.cjs.js","sources":["../src/parser.ts"],"sourcesContent":["/*\n * Copyright 2026 AAG1999 / contributors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\nimport type { TrendingRepository } from '@aag1999/plugin-github-trending-common';\n\nconst ARTICLE_RE =\n /<article\\b[^>]*\\bBox-row\\b[^>]*>([\\s\\S]*?)<\\/article>/gi;\nconst HREF_RE = /<h2\\b[^>]*>[\\s\\S]*?href=\"\\/([^\"/]+)\\/([^\"/?#]+)\"/i;\nconst DESC_RE = /<p\\b[^>]*col-9[^>]*>([\\s\\S]*?)<\\/p>/i;\nconst LANG_RE = /itemprop=\"programmingLanguage\">\\s*([^<]+)\\s*</i;\nconst STARS_RE = /href=\"\\/[^\"]+\\/stargazers\"[^>]*>[\\s\\S]*?([\\d,]+)\\s*</i;\nconst FORKS_RE = /href=\"\\/[^\"]+\\/(?:network\\/members|forks)\"[^>]*>[\\s\\S]*?([\\d,]+)\\s*</i;\nconst PERIOD_RE =\n /([\\d,]+)\\s+stars\\s+(today|this week|this month)/i;\n\nfunction parseCount(raw: string | undefined): number {\n if (!raw) {\n return 0;\n }\n const n = Number(raw.replace(/,/g, '').trim());\n return Number.isFinite(n) ? n : 0;\n}\n\nfunction decodeEntities(text: string): string {\n return text\n .replace(/<[^>]+>/g, '')\n .replace(/&amp;/g, '&')\n .replace(/&lt;/g, '<')\n .replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"')\n .replace(/&#39;/g, \"'\")\n .replace(/&nbsp;/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * Parse github.com/trending HTML into structured rows.\n * Markup is treated as unstable; keep fixtures in lockstep with GitHub.\n */\nexport function parseTrendingHtml(\n html: string,\n siteOrigin = 'https://github.com',\n): TrendingRepository[] {\n const origin = siteOrigin.replace(/\\/$/, '');\n const rows: TrendingRepository[] = [];\n const articleRe = new RegExp(ARTICLE_RE.source, ARTICLE_RE.flags);\n let rank = 0;\n for (;;) {\n const match = articleRe.exec(html);\n if (!match) {\n break;\n }\n const body = match[1];\n const href = body.match(HREF_RE);\n if (!href) {\n continue;\n }\n rank += 1;\n const owner = href[1];\n const name = href[2];\n const descMatch = body.match(DESC_RE);\n const langMatch = body.match(LANG_RE);\n const periodMatch = body.match(PERIOD_RE);\n rows.push({\n rank,\n owner,\n name,\n url: `${origin}/${owner}/${name}`,\n description: descMatch ? decodeEntities(descMatch[1]) : undefined,\n language: langMatch ? decodeEntities(langMatch[1]) : undefined,\n stars: parseCount(body.match(STARS_RE)?.[1]),\n forks: parseCount(body.match(FORKS_RE)?.[1]),\n starsInPeriod: parseCount(periodMatch?.[1]),\n periodLabel: periodMatch?.[2] ?? 'today',\n });\n }\n return rows;\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ var backendPluginApi = require('@backstage/backend-plugin-api');
4
+ var router = require('./router.cjs.js');
5
+
6
+ const githubTrendingPlugin = backendPluginApi.createBackendPlugin({
7
+ pluginId: "github-trending",
8
+ register(env) {
9
+ env.registerInit({
10
+ deps: {
11
+ httpRouter: backendPluginApi.coreServices.httpRouter,
12
+ logger: backendPluginApi.coreServices.logger,
13
+ config: backendPluginApi.coreServices.rootConfig
14
+ },
15
+ async init({ httpRouter, logger, config }) {
16
+ httpRouter.use(await router.createRouter({ logger, config }));
17
+ httpRouter.addAuthPolicy({
18
+ path: "/health",
19
+ allow: "unauthenticated"
20
+ });
21
+ }
22
+ });
23
+ }
24
+ });
25
+
26
+ exports.githubTrendingPlugin = githubTrendingPlugin;
27
+ //# sourceMappingURL=plugin.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.cjs.js","sources":["../src/plugin.ts"],"sourcesContent":["/*\n * Copyright 2026 AAG1999 / contributors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\nimport {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport { createRouter } from './router';\n\n/**\n * Fetches and caches github.com/trending for the home ticker.\n * @public\n */\nexport const githubTrendingPlugin = createBackendPlugin({\n pluginId: 'github-trending',\n register(env) {\n env.registerInit({\n deps: {\n httpRouter: coreServices.httpRouter,\n logger: coreServices.logger,\n config: coreServices.rootConfig,\n },\n async init({ httpRouter, logger, config }) {\n httpRouter.use(await createRouter({ logger, config }));\n httpRouter.addAuthPolicy({\n path: '/health',\n allow: 'unauthenticated',\n });\n },\n });\n },\n});\n"],"names":["createBackendPlugin","coreServices","createRouter"],"mappings":";;;;;AAoBO,MAAM,uBAAuBA,oCAAA,CAAoB;AAAA,EACtD,QAAA,EAAU,iBAAA;AAAA,EACV,SAAS,GAAA,EAAK;AACZ,IAAA,GAAA,CAAI,YAAA,CAAa;AAAA,MACf,IAAA,EAAM;AAAA,QACJ,YAAYC,6BAAA,CAAa,UAAA;AAAA,QACzB,QAAQA,6BAAA,CAAa,MAAA;AAAA,QACrB,QAAQA,6BAAA,CAAa;AAAA,OACvB;AAAA,MACA,MAAM,IAAA,CAAK,EAAE,UAAA,EAAY,MAAA,EAAQ,QAAO,EAAG;AACzC,QAAA,UAAA,CAAW,IAAI,MAAMC,mBAAA,CAAa,EAAE,MAAA,EAAQ,MAAA,EAAQ,CAAC,CAAA;AACrD,QAAA,UAAA,CAAW,aAAA,CAAc;AAAA,UACvB,IAAA,EAAM,SAAA;AAAA,UACN,KAAA,EAAO;AAAA,SACR,CAAA;AAAA,MACH;AAAA,KACD,CAAA;AAAA,EACH;AACF,CAAC;;"}
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ var errors = require('@backstage/errors');
4
+ var express = require('express');
5
+ var Router = require('express-promise-router');
6
+ var cache = require('./cache.cjs.js');
7
+ var parser = require('./parser.cjs.js');
8
+
9
+ function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
10
+
11
+ var express__default = /*#__PURE__*/_interopDefaultCompat(express);
12
+ var Router__default = /*#__PURE__*/_interopDefaultCompat(Router);
13
+
14
+ const USER_AGENT = "backstage-plugin-github-trending/0.1 (+https://github.com/AAG1999/backstage-plugin-github-trending)";
15
+ const ALLOWED_SINCE = ["daily", "weekly", "monthly"];
16
+ function readSince(raw, fallback) {
17
+ if (raw === void 0 || raw === "") {
18
+ return fallback;
19
+ }
20
+ if (typeof raw === "string" && ALLOWED_SINCE.includes(raw)) {
21
+ return raw;
22
+ }
23
+ throw new errors.InputError(`since must be one of ${ALLOWED_SINCE.join(", ")}`);
24
+ }
25
+ function buildSourceUrl(opts) {
26
+ const url = new URL(
27
+ opts.language ? `/trending/${encodeURIComponent(opts.language)}` : "/trending",
28
+ opts.baseUrl
29
+ );
30
+ if (opts.since !== "daily") {
31
+ url.searchParams.set("since", opts.since);
32
+ }
33
+ if (opts.spokenLanguageCode) {
34
+ url.searchParams.set("spoken_language_code", opts.spokenLanguageCode);
35
+ }
36
+ return url.toString();
37
+ }
38
+ async function createRouter(options) {
39
+ const { logger, config } = options;
40
+ const fetchFn = options.fetchFn ?? fetch;
41
+ const cache$1 = new cache.MemoryTtlCache();
42
+ const trendingConfig = config.getOptionalConfig("githubTrending");
43
+ const baseUrl = (trendingConfig?.getOptionalString("baseUrl") ?? "https://github.com").replace(/\/$/, "");
44
+ const defaultSince = readSince(
45
+ trendingConfig?.getOptionalString("since"),
46
+ "daily"
47
+ );
48
+ const defaultLanguage = trendingConfig?.getOptionalString("language");
49
+ const defaultSpoken = trendingConfig?.getOptionalString("spokenLanguageCode");
50
+ const ttlMs = (trendingConfig?.getOptionalNumber("cacheTtlMinutes") ?? 45) * 6e4;
51
+ const router = Router__default.default();
52
+ router.use(express__default.default.json());
53
+ router.get("/health", (_req, res) => {
54
+ res.json({ status: "ok" });
55
+ });
56
+ router.get("/repositories", async (req, res) => {
57
+ const since = readSince(req.query.since, defaultSince);
58
+ const language = typeof req.query.language === "string" && req.query.language ? req.query.language : defaultLanguage;
59
+ const spokenLanguageCode = typeof req.query.spoken_language_code === "string" && req.query.spoken_language_code ? req.query.spoken_language_code : defaultSpoken;
60
+ const cacheKey = JSON.stringify({ since, language, spokenLanguageCode });
61
+ const cached = cache$1.get(cacheKey);
62
+ if (cached) {
63
+ res.json(cached);
64
+ return;
65
+ }
66
+ const sourceUrl = buildSourceUrl({
67
+ baseUrl,
68
+ since,
69
+ language,
70
+ spokenLanguageCode
71
+ });
72
+ logger.info(`Fetching GitHub Trending ${sourceUrl}`);
73
+ const controller = new AbortController();
74
+ const timeout = setTimeout(() => controller.abort(), 15e3);
75
+ let html;
76
+ try {
77
+ const response = await fetchFn(sourceUrl, {
78
+ headers: {
79
+ Accept: "text/html",
80
+ "User-Agent": USER_AGENT
81
+ },
82
+ signal: controller.signal
83
+ });
84
+ if (!response.ok) {
85
+ logger.warn(
86
+ `GitHub Trending returned HTTP ${response.status} for ${sourceUrl}`
87
+ );
88
+ res.status(502).json({
89
+ error: `Upstream GitHub Trending returned HTTP ${response.status}`
90
+ });
91
+ return;
92
+ }
93
+ html = await response.text();
94
+ } catch (error) {
95
+ logger.warn(`GitHub Trending fetch failed: ${error}`);
96
+ res.status(502).json({ error: "Failed to fetch GitHub Trending" });
97
+ return;
98
+ } finally {
99
+ clearTimeout(timeout);
100
+ }
101
+ const payload = {
102
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
103
+ since,
104
+ language,
105
+ spokenLanguageCode,
106
+ sourceUrl,
107
+ repositories: parser.parseTrendingHtml(html, baseUrl)
108
+ };
109
+ cache$1.set(cacheKey, payload, ttlMs);
110
+ res.json(payload);
111
+ });
112
+ return router;
113
+ }
114
+
115
+ exports.createRouter = createRouter;
116
+ //# sourceMappingURL=router.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.cjs.js","sources":["../src/router.ts"],"sourcesContent":["/*\n * Copyright 2026 AAG1999 / contributors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\nimport { Config } from '@backstage/config';\nimport { LoggerService } from '@backstage/backend-plugin-api';\nimport { InputError } from '@backstage/errors';\nimport type {\n TrendingResponse,\n TrendingSince,\n} from '@aag1999/plugin-github-trending-common';\nimport express from 'express';\nimport Router from 'express-promise-router';\nimport { MemoryTtlCache } from './cache';\nimport { parseTrendingHtml } from './parser';\n\nconst USER_AGENT =\n 'backstage-plugin-github-trending/0.1 (+https://github.com/AAG1999/backstage-plugin-github-trending)';\nconst ALLOWED_SINCE: TrendingSince[] = ['daily', 'weekly', 'monthly'];\n\nexport type RouterOptions = {\n logger: LoggerService;\n config: Config;\n fetchFn?: typeof fetch;\n};\n\nfunction readSince(raw: unknown, fallback: TrendingSince): TrendingSince {\n if (raw === undefined || raw === '') {\n return fallback;\n }\n if (typeof raw === 'string' && ALLOWED_SINCE.includes(raw as TrendingSince)) {\n return raw as TrendingSince;\n }\n throw new InputError(`since must be one of ${ALLOWED_SINCE.join(', ')}`);\n}\n\nfunction buildSourceUrl(opts: {\n baseUrl: string;\n since: TrendingSince;\n language?: string;\n spokenLanguageCode?: string;\n}): string {\n const url = new URL(\n opts.language\n ? `/trending/${encodeURIComponent(opts.language)}`\n : '/trending',\n opts.baseUrl,\n );\n if (opts.since !== 'daily') {\n url.searchParams.set('since', opts.since);\n }\n if (opts.spokenLanguageCode) {\n url.searchParams.set('spoken_language_code', opts.spokenLanguageCode);\n }\n return url.toString();\n}\n\nexport async function createRouter(\n options: RouterOptions,\n): Promise<express.Router> {\n const { logger, config } = options;\n const fetchFn = options.fetchFn ?? fetch;\n const cache = new MemoryTtlCache<TrendingResponse>();\n const trendingConfig = config.getOptionalConfig('githubTrending');\n const baseUrl = (\n trendingConfig?.getOptionalString('baseUrl') ?? 'https://github.com'\n ).replace(/\\/$/, '');\n const defaultSince = readSince(\n trendingConfig?.getOptionalString('since'),\n 'daily',\n );\n const defaultLanguage = trendingConfig?.getOptionalString('language');\n const defaultSpoken =\n trendingConfig?.getOptionalString('spokenLanguageCode');\n const ttlMs =\n (trendingConfig?.getOptionalNumber('cacheTtlMinutes') ?? 45) * 60_000;\n\n const router = Router();\n router.use(express.json());\n\n router.get('/health', (_req, res) => {\n res.json({ status: 'ok' });\n });\n\n router.get('/repositories', async (req, res) => {\n const since = readSince(req.query.since, defaultSince);\n const language =\n typeof req.query.language === 'string' && req.query.language\n ? req.query.language\n : defaultLanguage;\n const spokenLanguageCode =\n typeof req.query.spoken_language_code === 'string' &&\n req.query.spoken_language_code\n ? req.query.spoken_language_code\n : defaultSpoken;\n const cacheKey = JSON.stringify({ since, language, spokenLanguageCode });\n const cached = cache.get(cacheKey);\n if (cached) {\n res.json(cached);\n return;\n }\n\n const sourceUrl = buildSourceUrl({\n baseUrl,\n since,\n language,\n spokenLanguageCode,\n });\n logger.info(`Fetching GitHub Trending ${sourceUrl}`);\n\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 15_000);\n let html: string;\n try {\n const response = await fetchFn(sourceUrl, {\n headers: {\n Accept: 'text/html',\n 'User-Agent': USER_AGENT,\n },\n signal: controller.signal,\n });\n if (!response.ok) {\n logger.warn(\n `GitHub Trending returned HTTP ${response.status} for ${sourceUrl}`,\n );\n res.status(502).json({\n error: `Upstream GitHub Trending returned HTTP ${response.status}`,\n });\n return;\n }\n html = await response.text();\n } catch (error) {\n logger.warn(`GitHub Trending fetch failed: ${error}`);\n res.status(502).json({ error: 'Failed to fetch GitHub Trending' });\n return;\n } finally {\n clearTimeout(timeout);\n }\n\n const payload: TrendingResponse = {\n fetchedAt: new Date().toISOString(),\n since,\n language,\n spokenLanguageCode,\n sourceUrl,\n repositories: parseTrendingHtml(html, baseUrl),\n };\n cache.set(cacheKey, payload, ttlMs);\n res.json(payload);\n });\n\n return router;\n}\n"],"names":["InputError","cache","MemoryTtlCache","Router","express","parseTrendingHtml"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@aag1999/plugin-github-trending-backend",
3
+ "version": "0.1.0",
4
+ "description": "Backend for the GitHub Trending Backstage plugin",
5
+ "backstage": {
6
+ "role": "backend-plugin",
7
+ "pluginId": "github-trending",
8
+ "pluginPackages": [
9
+ "@aag1999/plugin-github-trending",
10
+ "@aag1999/plugin-github-trending-backend",
11
+ "@aag1999/plugin-github-trending-common"
12
+ ],
13
+ "features": {
14
+ ".": "@backstage/BackendFeature"
15
+ }
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/AAG1999/backstage-plugin-github-trending",
23
+ "directory": "plugins/github-trending-backend"
24
+ },
25
+ "license": "Apache-2.0",
26
+ "main": "./dist/index.cjs.js",
27
+ "types": "./dist/index.d.ts",
28
+ "files": [
29
+ "dist",
30
+ "config.schema.json"
31
+ ],
32
+ "scripts": {
33
+ "start": "backstage-cli package start",
34
+ "build": "backstage-cli package build",
35
+ "lint": "backstage-cli package lint",
36
+ "test": "backstage-cli package test",
37
+ "clean": "backstage-cli package clean",
38
+ "prepack": "backstage-cli package prepack",
39
+ "postpack": "backstage-cli package postpack"
40
+ },
41
+ "dependencies": {
42
+ "@aag1999/plugin-github-trending-common": "workspace:^",
43
+ "@backstage/backend-plugin-api": "^1.10.0",
44
+ "@backstage/config": "^1.3.8",
45
+ "@backstage/errors": "^1.2.7",
46
+ "express": "^4.17.1",
47
+ "express-promise-router": "^4.1.0",
48
+ "yn": "^4.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@backstage/backend-test-utils": "^1.11.6",
52
+ "@backstage/cli": "^0.36.5",
53
+ "@types/express": "^4.17.21",
54
+ "@types/supertest": "^6.0.0",
55
+ "supertest": "^7.0.0"
56
+ },
57
+ "typesVersions": {
58
+ "*": {
59
+ "package.json": [
60
+ "package.json"
61
+ ]
62
+ }
63
+ }
64
+ }