From 4830f1df8c3584016e88c602eb673f0703f01752 Mon Sep 17 00:00:00 2001 From: Bailey Townsend Date: Sat, 31 Jan 2026 23:21:09 -0600 Subject: [PATCH 01/19] generic cache. local file in dev, redis in prod --- nuxt.config.ts | 4 ++ server/utils/cache.ts | 109 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 server/utils/cache.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 8ba28e7b4..2cbb8e80c 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -168,6 +168,10 @@ export default defineNuxtConfig({ driver: 'fsLite', base: './.cache/atproto-oauth/session', }, + 'generic-cache': { + driver: 'fsLite', + base: './.cache/generic', + }, }, typescript: { tsConfig: { diff --git a/server/utils/cache.ts b/server/utils/cache.ts new file mode 100644 index 000000000..0526ff447 --- /dev/null +++ b/server/utils/cache.ts @@ -0,0 +1,109 @@ +import { Redis } from '@upstash/redis' + +/** + * Generic cache adapter to allow using a local cache during development and redis in production + */ +export interface CacheAdapter { + get(key: string): Promise + set(key: string, value: T, ttl?: number): Promise + delete(key: string): Promise +} + +/** + * Local cache data entry + */ +interface LocalCachedEntry { + value: T + ttl?: number + cachedAt: number +} + +/** + * Checks to see if a cache entry is stale locally + * @param entry - The entry from the locla cache + * @returns + */ +function isCacheEntryStale(entry: LocalCachedEntry): boolean { + if (!entry.ttl) return false + const now = Date.now() + const expiresAt = entry.cachedAt + entry.ttl * 1000 + return now > expiresAt +} + +/** + * Local implmentation of a cache to be used during development + */ +export class StorageCacheAdapter implements CacheAdapter { + private readonly storage = useStorage('generic-cache') + + async get(key: string): Promise { + const result = await this.storage.getItem>(key) + if (!result) return + if (isCacheEntryStale(result)) { + await this.storage.removeItem(key) + return + } + return result.value + } + + async set(key: string, value: T, ttl?: number): Promise { + await this.storage.setItem(key, { value, ttl, cachedAt: Date.now() }) + } + + async delete(key: string): Promise { + await this.storage.removeItem(key) + } +} + +/** + * Redis cache storage with TTL handled by redis for use in production + */ +export class RedisCacheAdatper implements CacheAdapter { + private readonly redis: Redis + private readonly prefix: string + + formatKey(key: string): string { + return `${this.prefix}:${key}` + } + + constructor(redis: Redis, prefix: string) { + this.redis = redis + this.prefix = prefix + } + + async get(key: string): Promise { + const formattedKey = this.formatKey(key) + const value = await this.redis.get(formattedKey) + if (!value) return + return value + } + + async set(key: string, value: T, ttl?: number): Promise { + const formattedKey = this.formatKey(key) + if (ttl) { + await this.redis.setex(formattedKey, ttl, value) + } else { + await this.redis.set(formattedKey, value) + } + } + + async delete(key: string): Promise { + const formattedKey = this.formatKey(key) + await this.redis.del(formattedKey) + } +} + +export function getCache(prefix: string): CacheAdapter { + const config = useRuntimeConfig() + + if (!import.meta.dev && config.upstash?.redisRestUrl && config.upstash?.redisRestToken) { + const redis = new Redis({ + url: config.upstash.redisRestUrl, + token: config.upstash.redisRestToken, + }) + return new RedisCacheAdatper(redis, prefix) + } + + console.log('using storage') + return new StorageCacheAdapter() +} From 5637945736c116136e3601a94e726e99d6f043e9 Mon Sep 17 00:00:00 2001 From: Bailey Townsend Date: Sun, 1 Feb 2026 00:52:07 -0600 Subject: [PATCH 02/19] Ideally should be the logic to read/set likes for npmx --- server/api/auth/atproto.get.ts | 2 +- server/api/likes/[...pkg].get.ts | 4 + server/utils/atproto/utils/likes.ts | 154 ++++++++++++++++++++++++++++ server/utils/cache.ts | 4 +- shared/utils/constants.ts | 4 + 5 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 server/api/likes/[...pkg].get.ts create mode 100644 server/utils/atproto/utils/likes.ts diff --git a/server/api/auth/atproto.get.ts b/server/api/auth/atproto.get.ts index 287823ce7..66bc145f9 100644 --- a/server/api/auth/atproto.get.ts +++ b/server/api/auth/atproto.get.ts @@ -10,7 +10,7 @@ export default defineEventHandler(async event => { if (!config.sessionPassword) { throw createError({ status: 500, - message: 'NUXT_SESSION_PASSWORD not set', + message: UNSET_NUXT_SESSION_PASSWORD, }) } diff --git a/server/api/likes/[...pkg].get.ts b/server/api/likes/[...pkg].get.ts new file mode 100644 index 000000000..040201e4c --- /dev/null +++ b/server/api/likes/[...pkg].get.ts @@ -0,0 +1,4 @@ +export default defineEventHandler(async _ => { + //TODO: Use the new thing I wrote last night + return 0 +}) diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts new file mode 100644 index 000000000..9ce678a4f --- /dev/null +++ b/server/utils/atproto/utils/likes.ts @@ -0,0 +1,154 @@ +import { getCacheAdatper } from '../../cache' +import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs' +import { SUBJECT_REF_PREFIX } from '~~/shared/utils/constants' + +/** + * Likes for a npm package on npmx + */ +export type PackageLikes = { + // The total likes found for the package + totalLikes: number + // If the logged in user has liked the package, false if not logged in + userHasLiked: boolean +} + +const CACHE_PREFIX = 'atproto-likes:' +const CACHE_PACKAGE_TOTAL_KEY = (packageName: string) => `${CACHE_PREFIX}:${packageName}:total` +const CACHE_USER_LIKES_KEY = (packageName: string, did: string) => + `${CACHE_PREFIX}${packageName}users:${did}` + +const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5 + +export class PackageLikesService { + private readonly constellation: Constellation + private readonly cache: CacheAdapter + + constructor(cachedFunction: CachedFetchFunction) { + this.constellation = new Constellation(cachedFunction) + this.cache = getCacheAdatper(CACHE_PREFIX) + } + + /** + * Gets the true total count of likes for a npm package from the network + * @param subjectRef + * @returns + */ + private async constellationLikes(subjectRef: string) { + // TODO: I need to see what failed fetch calls do here + const { data: totalLinks } = await this.constellation.getLinksDistinctDids( + subjectRef, + likeNsid, + '.subjectRef', + //Limit doesn't matter here since we are just counting the total likes + 1, + undefined, + CACHE_MAX_AGE_ONE_MINUTE * 10, + ) + return totalLinks.total + } + + /** + * Checks if the user has liked the npm package from the network + * @param subjectRef + * @param usersDid + * @returns + */ + private async constellationUserHasLiked(subjectRef: string, usersDid: string) { + const { data: userLikes } = await this.constellation.getBackLinks( + subjectRef, + likeNsid, + 'subjectRef', + //Limit doesn't matter here since we are just counting the total likes + 1, + undefined, + false, + [[usersDid]], + ) + //TODO: need to double check this logic + return userLikes.total > 0 + } + + /** + * Gets the likes for a npm package on npmx. Tries a local cahce first, if not found uses constellation + * @param packageName + * @param usersDid + * @returns + */ + async getLikes(packageName: string, usersDid?: string) { + //TODO: May need to do some clean up on the package name, and maybe even hash it? some of the charcteres may be a bit odd as keys + const cache = getCacheAdatper(CACHE_PREFIX) + + const cachedLikes = await cache.get(packageName) + if (cachedLikes) { + return cachedLikes + } + + const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}` + + const totalLikes = await this.constellationLikes(subjectRef) + + let userHasLiked = false + + if (usersDid) { + userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid) + } + + const packageLikes = { + totalPackageLikes: totalLikes, + userHasLiked, + } + if (userHasLiked && usersDid) { + await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE) + } + + const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) + await cache.set(totalLikesKey, packageLikes.totalPackageLikes, CACHE_MAX_AGE) + return packageLikes + } + + /** + * Gets the definite answer if the user has liked a npm package. Either from the cache or the network + * @param packageName + * @param usersDid + * @returns + */ + async hasTheUserLikedThePackage(packageName: string, usersDid: string) { + const cache = getCacheAdatper(CACHE_PREFIX) + const cached = await cache.get(CACHE_USER_LIKES_KEY(packageName, usersDid)) + if (cached !== undefined) { + return cached + } + const userHasLiked = await this.constellationUserHasLiked( + `${SUBJECT_REF_PREFIX}/${packageName}`, + usersDid, + ) + await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE) + return userHasLiked + } + + /** + * It is asummed it has been checked by this point that if a user has liked a package and the new like was made as a record + * to the user's atproto repostiory + * @param packageName + * @param usersDid + */ + async likeAPackageAndRetunLikes(packageName: string, usersDid: string): Promise { + const cache = getCacheAdatper(CACHE_PREFIX) + + const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) + let totalLikes = await cache.get(totalLikesKey) + // If a cahce entry was found for total likes increase by 1 + if (totalLikes !== undefined) { + await cache.set(totalLikesKey, totalLikes + 1, CACHE_MAX_AGE) + } else { + const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}` + totalLikes = await this.constellationLikes(subjectRef) + } + // We already know the user has not liked the package so set in the cache + await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE) + return { + totalLikes: totalLikes, + userHasLiked: true, + } + } +} diff --git a/server/utils/cache.ts b/server/utils/cache.ts index 0526ff447..21910753b 100644 --- a/server/utils/cache.ts +++ b/server/utils/cache.ts @@ -93,7 +93,7 @@ export class RedisCacheAdatper implements CacheAdapter { } } -export function getCache(prefix: string): CacheAdapter { +export function getCacheAdatper(prefix: string): CacheAdapter { const config = useRuntimeConfig() if (!import.meta.dev && config.upstash?.redisRestUrl && config.upstash?.redisRestToken) { @@ -103,7 +103,5 @@ export function getCache(prefix: string): CacheAdapter { }) return new RedisCacheAdatper(redis, prefix) } - - console.log('using storage') return new StorageCacheAdapter() } diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 582383508..91a764fc0 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -30,6 +30,10 @@ export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible." export const CONSTELLATION_HOST = 'constellation.microcosm.blue' export const SLINGSHOT_HOST = 'slingshot.microcosm.blue' +// ATProtocol +// Refrence prefix used to link packages to things that are not inherently atproto +export const SUBJECT_REF_PREFIX = 'https://npmx.dev' + // Theming export const ACCENT_COLORS = { rose: 'oklch(0.797 0.084 11.056)', From e708ea50ef8e335d7016521b9047c37fad82e50c Mon Sep 17 00:00:00 2001 From: Bailey Townsend Date: Sun, 1 Feb 2026 18:12:30 -0600 Subject: [PATCH 03/19] made the get cache actually work --- server/api/likes/[...pkg].get.ts | 23 +++++++++++-- server/utils/atproto/utils/likes.ts | 50 ++++++++++++++--------------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/server/api/likes/[...pkg].get.ts b/server/api/likes/[...pkg].get.ts index 040201e4c..a43fbe0fc 100644 --- a/server/api/likes/[...pkg].get.ts +++ b/server/api/likes/[...pkg].get.ts @@ -1,4 +1,21 @@ -export default defineEventHandler(async _ => { - //TODO: Use the new thing I wrote last night - return 0 +export default eventHandlerWithOAuthSession(async (event, oAuthSession, _) => { + const packageName = getRouterParam(event, 'pkg') + if (!packageName) { + throw createError({ + status: 400, + message: 'package name not provided', + }) + } + const cachedFetch = event.context.cachedFetch + if (!cachedFetch) { + // TODO: Probably needs to add in a normal fetch if not provided + // but ideally should not happen + throw createError({ + status: 500, + message: 'cachedFetch not provided in context', + }) + } + + const likesUtlil = new PackageLikesUtils(cachedFetch) + return await likesUtlil.getLikes(packageName, oAuthSession?.did.toString()) }) diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts index 9ce678a4f..606255605 100644 --- a/server/utils/atproto/utils/likes.ts +++ b/server/utils/atproto/utils/likes.ts @@ -15,11 +15,11 @@ export type PackageLikes = { const CACHE_PREFIX = 'atproto-likes:' const CACHE_PACKAGE_TOTAL_KEY = (packageName: string) => `${CACHE_PREFIX}:${packageName}:total` const CACHE_USER_LIKES_KEY = (packageName: string, did: string) => - `${CACHE_PREFIX}${packageName}users:${did}` + `${CACHE_PREFIX}${packageName}:users:${did}` const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5 -export class PackageLikesService { +export class PackageLikesUtils { private readonly constellation: Constellation private readonly cache: CacheAdapter @@ -74,35 +74,38 @@ export class PackageLikesService { * @param usersDid * @returns */ - async getLikes(packageName: string, usersDid?: string) { + async getLikes(packageName: string, usersDid?: string | undefined) { //TODO: May need to do some clean up on the package name, and maybe even hash it? some of the charcteres may be a bit odd as keys - const cache = getCacheAdatper(CACHE_PREFIX) + const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) + const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}` - const cachedLikes = await cache.get(packageName) + const cachedLikes = await this.cache.get(totalLikesKey) + let totalLikes = 0 if (cachedLikes) { - return cachedLikes + totalLikes = cachedLikes + } else { + totalLikes = await this.constellationLikes(subjectRef) + await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE) } - const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}` - - const totalLikes = await this.constellationLikes(subjectRef) - let userHasLiked = false - if (usersDid) { - userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid) + const userCachedLike = await this.cache.get( + CACHE_USER_LIKES_KEY(packageName, usersDid), + ) + if (userCachedLike) { + userHasLiked = userCachedLike + } else { + userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid) + await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE) + } } const packageLikes = { totalPackageLikes: totalLikes, userHasLiked, } - if (userHasLiked && usersDid) { - await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE) - } - const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) - await cache.set(totalLikesKey, packageLikes.totalPackageLikes, CACHE_MAX_AGE) return packageLikes } @@ -113,8 +116,7 @@ export class PackageLikesService { * @returns */ async hasTheUserLikedThePackage(packageName: string, usersDid: string) { - const cache = getCacheAdatper(CACHE_PREFIX) - const cached = await cache.get(CACHE_USER_LIKES_KEY(packageName, usersDid)) + const cached = await this.cache.get(CACHE_USER_LIKES_KEY(packageName, usersDid)) if (cached !== undefined) { return cached } @@ -122,7 +124,7 @@ export class PackageLikesService { `${SUBJECT_REF_PREFIX}/${packageName}`, usersDid, ) - await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE) + await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE) return userHasLiked } @@ -133,19 +135,17 @@ export class PackageLikesService { * @param usersDid */ async likeAPackageAndRetunLikes(packageName: string, usersDid: string): Promise { - const cache = getCacheAdatper(CACHE_PREFIX) - const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) - let totalLikes = await cache.get(totalLikesKey) + let totalLikes = await this.cache.get(totalLikesKey) // If a cahce entry was found for total likes increase by 1 if (totalLikes !== undefined) { - await cache.set(totalLikesKey, totalLikes + 1, CACHE_MAX_AGE) + await this.cache.set(totalLikesKey, totalLikes + 1, CACHE_MAX_AGE) } else { const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}` totalLikes = await this.constellationLikes(subjectRef) } // We already know the user has not liked the package so set in the cache - await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE) + await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE) return { totalLikes: totalLikes, userHasLiked: true, From 551e01f3f560620adfd35dfc8fa363babd099c45 Mon Sep 17 00:00:00 2001 From: Bailey Townsend Date: Sun, 1 Feb 2026 18:35:29 -0600 Subject: [PATCH 04/19] Should be proper read of likes on ui --- app/pages/package/[...package].vue | 41 +++++++++++++++++++++++++++++ server/utils/atproto/utils/likes.ts | 14 ++++------ shared/utils/constants.ts | 5 ++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue index 734a7e060..78b59dbca 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -352,6 +352,27 @@ const canonicalUrl = computed(() => { return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base }) +//atproto +const { user } = useAtproto() +const showAuthModal = ref(false) + +const { data: likesData } = useFetch(() => `/api/likes/${packageName.value}`, { + default: () => ({ totalPackageLikes: 0, userHasLiked: false }), +}) + +// const { mutate: likePackage } = useLikePackage(subjectRef) + +const likeAction = async () => { + if (user.value?.handle == null) { + showAuthModal.value = true + } else { + // const result = await likePackage() + // if (result?.likes) { + // likesData.value = result.likes + // } + } +} + useHead({ link: [{ rel: 'canonical', href: canonicalUrl }], }) @@ -501,6 +522,26 @@ defineOgImageComponent('Package', { + +