Skip to content

Commit afd6219

Browse files
feat(backend): add a GitHub releases cached middleware w/ cron
1 parent 2829362 commit afd6219

File tree

6 files changed

+268
-6
lines changed

6 files changed

+268
-6
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@total-typescript/ts-reset": "^0.6.1",
3030
"@types/eslint-config-prettier": "^6.11.3",
3131
"@types/semver": "^7.5.8",
32+
"@upstash/redis": "^1.34.6",
3233
"@vercel/speed-insights": "^1.2.0",
3334
"bits-ui": "^1.3.13",
3435
"clsx": "^2.1.1",

pnpm-lock.yaml

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/server/github-cache.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { GITHUB_TOKEN, KV_REST_API_TOKEN, KV_REST_API_URL } from "$env/static/private";
2+
import { Redis } from "@upstash/redis";
3+
import { Octokit } from "octokit";
4+
5+
export type GitHubRelease = Awaited<
6+
ReturnType<InstanceType<typeof Octokit>["rest"]["repos"]["listReleases"]>
7+
>["data"][number];
8+
9+
/**
10+
* A fetch layer to reach the GitHub API
11+
* with an additional caching mechanism.
12+
*/
13+
export class GitHubCache {
14+
readonly #redis: Redis;
15+
readonly #octokit: Octokit;
16+
17+
/**
18+
* Creates a new {@link GitHubCache} with the required auth info.
19+
*
20+
* @param redisUrl the Redis cache URL
21+
* @param redisToken the Redis cache token
22+
* @param githubToken the GitHub token for uncached API requests
23+
* @constructor
24+
*/
25+
constructor(redisUrl: string, redisToken: string, githubToken: string) {
26+
this.#redis = new Redis({
27+
url: redisUrl,
28+
token: redisToken
29+
});
30+
31+
this.#octokit = new Octokit({
32+
auth: githubToken
33+
});
34+
}
35+
36+
/**
37+
* Generates a Redis key from the passed info.
38+
*
39+
* @param owner the GitHub repository owner
40+
* @param repo the GitHub repository name
41+
* @returns the pure computed key
42+
* @private
43+
*/
44+
#getRepoKey(owner: string, repo: string) {
45+
return `repo:${owner}/${repo}:releases`;
46+
}
47+
48+
/**
49+
* Get all the releases for a given repository
50+
*
51+
* @param owner the owner of the GitHub repository to get releases from
52+
* @param repo the name of the GitHub repository to get releases from
53+
* @returns the releases, either cached or fetched
54+
*/
55+
async getReleases(owner: string, repo: string) {
56+
const cacheKey = this.#getRepoKey(owner, repo);
57+
58+
const cachedReleases = await this.#redis.json.get<GitHubRelease[]>(cacheKey);
59+
if (cachedReleases) {
60+
console.log(`Cache hit for ${cacheKey}`);
61+
return cachedReleases;
62+
}
63+
64+
console.log(`Cache miss for ${cacheKey}, fetching from GitHub API`);
65+
66+
const { data: releases } = await this.#octokit.rest.repos.listReleases({
67+
owner,
68+
repo,
69+
per_page: 50
70+
});
71+
72+
await this.#redis.json.set(cacheKey, "$", releases);
73+
74+
return releases;
75+
}
76+
77+
/**
78+
* Add the given releases to the repository cache
79+
*
80+
* @param owner the owner of the cached GitHub repository to add the
81+
* new releases into
82+
* @param repo the name of the cached GitHub repository to add the
83+
* new releases into
84+
* @param newReleases the new releases to add to the cache; they will then be
85+
* de-duped from the already existing ones, and sorted from most recent to oldest
86+
* @returns all the cached releases after the new releases have been applied
87+
*/
88+
async addReleases(owner: string, repo: string, newReleases: GitHubRelease[]) {
89+
const cacheKey = this.#getRepoKey(owner, repo);
90+
91+
// Get existing releases
92+
const existingReleases = (await this.#redis.json.get<GitHubRelease[]>(cacheKey)) ?? [];
93+
94+
// Dedupe them by ID
95+
const existingIds = new Set(existingReleases.map(release => release.id));
96+
const uniqueNewReleases = newReleases.filter(release => !existingIds.has(release.id));
97+
98+
// Merge them all
99+
const updatedReleases = [...existingReleases, ...uniqueNewReleases];
100+
101+
// Sort them by most recent
102+
updatedReleases.sort(
103+
(a, b) =>
104+
new Date(b.published_at ?? b.created_at).getTime() -
105+
new Date(a.published_at ?? a.created_at).getTime()
106+
);
107+
108+
await this.#redis.json.set(cacheKey, "$", updatedReleases);
109+
110+
return updatedReleases;
111+
}
112+
113+
/**
114+
* Fetch the latest releases for the given repository and add them
115+
* to the cache via {@link addReleases}
116+
*
117+
* @param owner the owner of the GitHub repository to fetch and cache
118+
* the releases for
119+
* @param repo the name of the GitHub repository to fetch and cache
120+
* the releases for
121+
* @returns the fetched releases
122+
*/
123+
async fetchAndCacheReleases(owner: string, repo: string) {
124+
const { data: releases } = await this.#octokit.rest.repos.listReleases({
125+
owner,
126+
repo,
127+
per_page: 50
128+
});
129+
130+
// Ajouter au cache pour le repo
131+
await this.addReleases(owner, repo, releases);
132+
133+
return releases;
134+
}
135+
136+
/**
137+
* Checks if releases are present in the cache for the
138+
* given GitHub info
139+
*
140+
* @param owner the owner of the GitHub repository to check the
141+
* existence in the cache for
142+
* @param repo the name of the GitHub repository to check the
143+
* existence in the cache for
144+
* @returns whether the repository is cached or not
145+
*/
146+
async exists(owner: string, repo: string) {
147+
const cacheKey = this.#getRepoKey(owner, repo);
148+
const result = await this.#redis.exists(cacheKey);
149+
return result === 1;
150+
}
151+
152+
/**
153+
* Delete a repository from the cache
154+
*
155+
* @param owner the owner of the GitHub repository to remove
156+
* from the cache
157+
* @param repo the name of the GitHub repository to remove
158+
* from the cache
159+
*/
160+
async deleteEntry(owner: string, repo: string) {
161+
const cacheKey = this.#getRepoKey(owner, repo);
162+
await this.#redis.del(cacheKey);
163+
}
164+
}
165+
166+
/**
167+
* A {@link GitHubCache} specifically meant for
168+
* repositories inside the `sveltejs` ownership
169+
*/
170+
export class SvelteGitHubCache {
171+
readonly #cache: GitHubCache;
172+
readonly #owner = "sveltejs";
173+
174+
/**
175+
* Creates the cache; see {@link GitHubCache.constructor} for more info
176+
*
177+
* @constructor
178+
*/
179+
constructor(redisUrl: string, redisToken: string, githubToken: string) {
180+
this.#cache = new GitHubCache(redisUrl, redisToken, githubToken);
181+
}
182+
183+
/**
184+
* Get the releases; see {@link GitHubCache.getReleases} for more info
185+
*/
186+
async getReleases(repo: string) {
187+
return await this.#cache.getReleases(this.#owner, repo);
188+
}
189+
190+
/**
191+
* Add new releases to the cache; see {@link GitHubCache.addReleases} for more info
192+
*/
193+
async addReleases(repo: string, newReleases: GitHubRelease[]) {
194+
return await this.#cache.addReleases(this.#owner, repo, newReleases);
195+
}
196+
197+
/**
198+
* Fetches and adds to the cache; see {@link GitHubCache.fetchAndCacheReleases} for more info
199+
*/
200+
async fetchAndCacheReleases(repo: string) {
201+
return await this.#cache.fetchAndCacheReleases(this.#owner, repo);
202+
}
203+
204+
/**
205+
* Checks if a cached entry exists; see {@link GitHubCache.exists} for more info
206+
*/
207+
async exists(repo: string) {
208+
return await this.#cache.exists(this.#owner, repo);
209+
}
210+
211+
/**
212+
* Deletes an entry; see {@link GitHubCache.deleteEntry} for more info
213+
*/
214+
async deleteEntry(repo: string) {
215+
await this.#cache.deleteEntry(this.#owner, repo);
216+
}
217+
}
218+
219+
export const svelteGitHubCache = new SvelteGitHubCache(
220+
KV_REST_API_URL,
221+
KV_REST_API_TOKEN,
222+
GITHUB_TOKEN
223+
);

src/lib/types.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Octokit } from "octokit";
1+
import type { GitHubRelease } from "$lib/server/github-cache";
22

33
export type Repo = {
44
/**
@@ -17,11 +17,7 @@ export type Repo = {
1717
*
1818
* @param release The release to filter
1919
*/
20-
dataFilter?: (
21-
release: Awaited<
22-
ReturnType<InstanceType<typeof Octokit>["rest"]["repos"]["listReleases"]>
23-
>["data"][number]
24-
) => boolean;
20+
dataFilter?: (release: GitHubRelease) => boolean;
2521
/**
2622
* Extracts the version from the tag name.
2723
*

src/routes/cron/+server.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { error, type RequestHandler } from "@sveltejs/kit";
2+
import { CRON_SECRET } from "$env/static/private";
3+
import { svelteGitHubCache } from "$lib/server/github-cache";
4+
import { getRepositories } from "$lib/repositories";
5+
6+
export const GET: RequestHandler = async ({ request }) => {
7+
if (request.headers.get("Authorization") !== `Bearer ${CRON_SECRET}`) {
8+
error(401);
9+
}
10+
11+
const allRepos = new Set(
12+
getRepositories().flatMap(([, { repos }]) => repos.map(r => r.repoName))
13+
);
14+
for (const repo of allRepos) {
15+
await svelteGitHubCache.fetchAndCacheReleases(repo);
16+
}
17+
18+
return new Response();
19+
};

vercel.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"crons": [
3+
{
4+
"path": "/cron",
5+
"schedule": "*/5 * * * *"
6+
}
7+
]
8+
}

0 commit comments

Comments
 (0)