From 86daef416f97e0f8ea7b1b35cb4fcca95160d815 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 4 Dec 2023 13:19:20 -0600 Subject: [PATCH] Cache trending tags (with code copied from Mostr) --- src/app.ts | 6 ++- src/middleware/cache.ts | 25 ++++++++++++ src/utils/expiring-cache.test.ts | 18 +++++++++ src/utils/expiring-cache.ts | 68 ++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/middleware/cache.ts create mode 100644 src/utils/expiring-cache.test.ts create mode 100644 src/utils/expiring-cache.ts diff --git a/src/app.ts b/src/app.ts index 776de8d..5ac2636 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,7 @@ import { serveStatic, } from '@/deps.ts'; import '@/firehose.ts'; +import { Time } from '@/utils.ts'; import { actorController } from './controllers/activitypub/actor.ts'; import { @@ -62,6 +63,7 @@ import { nostrController } from './controllers/well-known/nostr.ts'; import { webfingerController } from './controllers/well-known/webfinger.ts'; import { auth19, requirePubkey } from './middleware/auth19.ts'; import { auth98, requireProof, requireRole } from './middleware/auth98.ts'; +import { cache } from './middleware/cache.ts'; import { csp } from './middleware/csp.ts'; interface AppEnv extends HonoEnv { @@ -151,8 +153,8 @@ app.get('/api/v2/search', searchController); app.get('/api/pleroma/frontend_configurations', frontendConfigController); -app.get('/api/v1/trends/tags', trendingTagsController); -app.get('/api/v1/trends', trendingTagsController); +app.get('/api/v1/trends/tags', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController); +app.get('/api/v1/trends', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController); app.get('/api/v1/notifications', requirePubkey, notificationsController); app.get('/api/v1/favourites', requirePubkey, favouritesController); diff --git a/src/middleware/cache.ts b/src/middleware/cache.ts new file mode 100644 index 0000000..932632b --- /dev/null +++ b/src/middleware/cache.ts @@ -0,0 +1,25 @@ +import ExpiringCache from '@/utils/expiring-cache.ts'; + +import type { MiddlewareHandler } from '@/deps.ts'; + +export const cache = (options: { + cacheName: string; + expires?: number; +}): MiddlewareHandler => { + return async (c, next) => { + const key = c.req.url.replace('http://', 'https://'); + const cache = new ExpiringCache(await caches.open(options.cacheName)); + const response = await cache.match(key); + if (!response) { + console.debug('Building cache for page', c.req.url); + await next(); + const response = c.res.clone(); + if (response.status < 500) { + await cache.putExpiring(key, response, options.expires ?? 0); + } + } else { + console.debug('Serving page from cache', c.req.url); + return response; + } + }; +}; diff --git a/src/utils/expiring-cache.test.ts b/src/utils/expiring-cache.test.ts new file mode 100644 index 0000000..9827de8 --- /dev/null +++ b/src/utils/expiring-cache.test.ts @@ -0,0 +1,18 @@ +import { assert } from '@/deps-test.ts'; + +import ExpiringCache from './expiring-cache.ts'; + +Deno.test('ExpiringCache', async () => { + const cache = new ExpiringCache(await caches.open('test')); + + await cache.putExpiring('http://mostr.local/1', new Response('hello world'), 300); + await cache.putExpiring('http://mostr.local/2', new Response('hello world'), -1); + + // const resp1 = await cache.match('http://mostr.local/1'); + const resp2 = await cache.match('http://mostr.local/2'); + + // assert(resp1!.headers.get('Expires')); + assert(!resp2); + + // await resp1!.text(); +}); diff --git a/src/utils/expiring-cache.ts b/src/utils/expiring-cache.ts new file mode 100644 index 0000000..ebb5d2e --- /dev/null +++ b/src/utils/expiring-cache.ts @@ -0,0 +1,68 @@ +class ExpiringCache implements Cache { + #cache: Cache; + + constructor(cache: Cache) { + this.#cache = cache; + } + + add(request: RequestInfo | URL): Promise { + return this.#cache.add(request); + } + + addAll(requests: RequestInfo[]): Promise { + return this.#cache.addAll(requests); + } + + keys(request?: RequestInfo | URL | undefined, options?: CacheQueryOptions | undefined): Promise { + return this.#cache.keys(request, options); + } + + matchAll( + request?: RequestInfo | URL | undefined, + options?: CacheQueryOptions | undefined, + ): Promise { + return this.#cache.matchAll(request, options); + } + + put(request: RequestInfo | URL, response: Response): Promise { + return this.#cache.put(request, response); + } + + putExpiring(request: RequestInfo | URL, response: Response, expiresIn: number): Promise { + const expires = Date.now() + expiresIn; + + const clone = new Response(response.body, { + status: response.status, + headers: { + expires: new Date(expires).toUTCString(), + ...Object.fromEntries(response.headers.entries()), + }, + }); + + return this.#cache.put(request, clone); + } + + async match(request: RequestInfo | URL, options?: CacheQueryOptions | undefined): Promise { + const response = await this.#cache.match(request, options); + const expires = response?.headers.get('Expires'); + + if (response && expires) { + if (new Date(expires).getTime() > Date.now()) { + return response; + } else { + await Promise.all([ + this.delete(request), + response.text(), // Prevent memory leaks + ]); + } + } else if (response) { + return response; + } + } + + delete(request: RequestInfo | URL, options?: CacheQueryOptions | undefined): Promise { + return this.#cache.delete(request, options); + } +} + +export default ExpiringCache;