[READ-ONLY] a fast, modern browser for the npm registry

feat: package likes (#712)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by baileytownsend.dev

Daniel Roe and committed by
GitHub
bdaaa2c0 d8a30b49

+687 -27
+1
app/components/Header/AccountMenu.client.vue
··· 1 1 <script setup lang="ts"> 2 + import { useAtproto } from '~/composables/atproto/useAtproto' 2 3 import { useModal } from '~/composables/useModal' 3 4 4 5 const {
+6 -21
app/components/Header/AuthModal.client.vue
··· 1 1 <script setup lang="ts"> 2 + import { useAtproto } from '~/composables/atproto/useAtproto' 3 + import { authRedirect } from '~/utils/atproto/helpers' 4 + 2 5 const handleInput = shallowRef('') 3 6 4 7 const { user, logout } = useAtproto() 5 8 6 9 async function handleBlueskySignIn() { 7 - await navigateTo( 8 - { 9 - path: '/api/auth/atproto', 10 - query: { handle: 'https://bsky.social' }, 11 - }, 12 - { external: true }, 13 - ) 10 + await authRedirect('https://bsky.social') 14 11 } 15 12 16 13 async function handleCreateAccount() { 17 - await navigateTo( 18 - { 19 - path: '/api/auth/atproto', 20 - query: { handle: 'https://npmx.social', create: 'true' }, 21 - }, 22 - { external: true }, 23 - ) 14 + await authRedirect('https://npmx.social', true) 24 15 } 25 16 26 17 async function handleLogin() { 27 18 if (handleInput.value) { 28 - await navigateTo( 29 - { 30 - path: '/api/auth/atproto', 31 - query: { handle: handleInput.value }, 32 - }, 33 - { external: true }, 34 - ) 19 + await authRedirect(handleInput.value) 35 20 } 36 21 } 37 22 </script>
+1
app/components/Header/MobileMenu.client.vue
··· 1 1 <script setup lang="ts"> 2 2 import { useFocusTrap } from '@vueuse/integrations/useFocusTrap' 3 + import { useAtproto } from '~/composables/atproto/useAtproto' 3 4 4 5 const isOpen = defineModel<boolean>('open', { default: false }) 5 6
+2 -2
app/composables/useAtproto.ts app/composables/atproto/useAtproto.ts
··· 1 - export function useAtproto() { 1 + export const useAtproto = createSharedComposable(function useAtproto() { 2 2 const { 3 3 data: user, 4 4 pending, ··· 17 17 } 18 18 19 19 return { user, pending, logout } 20 - } 20 + })
+72
app/pages/package/[...package].vue
··· 12 12 import { isEditableElement } from '~/utils/input' 13 13 import { formatBytes } from '~/utils/formatters' 14 14 import { NuxtLink } from '#components' 15 + import { useModal } from '~/composables/useModal' 16 + import { useAtproto } from '~/composables/atproto/useAtproto' 17 + import { togglePackageLike } from '~/utils/atproto/likes' 15 18 16 19 definePageMeta({ 17 20 name: 'package', ··· 356 359 return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base 357 360 }) 358 361 362 + //atproto 363 + // TODO: Maybe set this where it's not loaded here every load? 364 + const { user } = useAtproto() 365 + 366 + const authModal = useModal('auth-modal') 367 + 368 + const { data: likesData } = useFetch(() => `/api/social/likes/${packageName.value}`, { 369 + default: () => ({ totalLikes: 0, userHasLiked: false }), 370 + server: false, 371 + }) 372 + 373 + const isLikeActionPending = ref(false) 374 + 375 + const likeAction = async () => { 376 + if (user.value?.handle == null) { 377 + authModal.open() 378 + return 379 + } 380 + 381 + if (isLikeActionPending.value) return 382 + 383 + const currentlyLiked = likesData.value?.userHasLiked ?? false 384 + const currentLikes = likesData.value?.totalLikes ?? 0 385 + 386 + // Optimistic update 387 + likesData.value = { 388 + totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1, 389 + userHasLiked: !currentlyLiked, 390 + } 391 + 392 + isLikeActionPending.value = true 393 + 394 + const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle) 395 + 396 + isLikeActionPending.value = false 397 + 398 + if (result.success) { 399 + // Update with server response 400 + likesData.value = result.data 401 + } else { 402 + // Revert on error 403 + likesData.value = { 404 + totalLikes: currentLikes, 405 + userHasLiked: currentlyLiked, 406 + } 407 + } 408 + } 409 + 359 410 useHead({ 360 411 link: [{ rel: 'canonical', href: canonicalUrl }], 361 412 }) ··· 497 548 :is-binary="isBinaryOnly" 498 549 class="self-baseline ms-1 sm:ms-2" 499 550 /> 551 + 552 + <!-- Package likes --> 553 + <button 554 + @click="likeAction" 555 + type="button" 556 + class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200" 557 + :title="$t('package.links.like')" 558 + > 559 + <span 560 + :class=" 561 + likesData?.userHasLiked 562 + ? 'i-lucide-heart-minus text-red-500' 563 + : 'i-lucide-heart-plus' 564 + " 565 + class="w-4 h-4" 566 + aria-hidden="true" 567 + /> 568 + <span>{{ formatCompactNumber(likesData?.totalLikes ?? 0, { decimals: 1 }) }}</span> 569 + </button> 570 + 500 571 <template #fallback> 501 572 <div class="flex items-center gap-1.5 self-baseline ms-1 sm:ms-2"> 502 573 <SkeletonBlock class="w-8 h-5 rounded" /> 503 574 <SkeletonBlock class="w-12 h-5 rounded" /> 575 + <SkeletonBlock class="w-5 h-5 rounded" /> 504 576 </div> 505 577 </template> 506 578 </ClientOnly>
+30
app/utils/atproto/helpers.ts
··· 1 + import type { FetchError } from 'ofetch' 2 + import type { LocationQueryRaw } from 'vue-router' 3 + 4 + /** 5 + * Redirect user to ATProto authentication 6 + */ 7 + export async function authRedirect(identifier: string, create: boolean = false) { 8 + let query: LocationQueryRaw = { handle: identifier } 9 + if (create) { 10 + query = { ...query, create: 'true' } 11 + } 12 + await navigateTo( 13 + { 14 + path: '/api/auth/atproto', 15 + query, 16 + }, 17 + { external: true }, 18 + ) 19 + } 20 + 21 + export async function handleAuthError( 22 + fetchError: FetchError, 23 + userHandle?: string | null, 24 + ): Promise<never> { 25 + const errorMessage = fetchError?.data?.message 26 + if (errorMessage === ERROR_NEED_REAUTH && userHandle) { 27 + await authRedirect(userHandle) 28 + } 29 + throw fetchError 30 + }
+60
app/utils/atproto/likes.ts
··· 1 + import { FetchError } from 'ofetch' 2 + import { handleAuthError } from '~/utils/atproto/helpers' 3 + import type { PackageLikes } from '#shared/types/social' 4 + 5 + export type LikeResult = { success: true; data: PackageLikes } | { success: false; error: Error } 6 + 7 + /** 8 + * Like a package via the API 9 + */ 10 + export async function likePackage( 11 + packageName: string, 12 + userHandle?: string | null, 13 + ): Promise<LikeResult> { 14 + try { 15 + const result = await $fetch<PackageLikes>('/api/social/like', { 16 + method: 'POST', 17 + body: { packageName }, 18 + }) 19 + return { success: true, data: result } 20 + } catch (e) { 21 + if (e instanceof FetchError) { 22 + await handleAuthError(e, userHandle) 23 + } 24 + return { success: false, error: e as Error } 25 + } 26 + } 27 + 28 + /** 29 + * Unlike a package via the API 30 + */ 31 + export async function unlikePackage( 32 + packageName: string, 33 + userHandle?: string | null, 34 + ): Promise<LikeResult> { 35 + try { 36 + const result = await $fetch<PackageLikes>('/api/social/like', { 37 + method: 'DELETE', 38 + body: { packageName }, 39 + }) 40 + return { success: true, data: result } 41 + } catch (e) { 42 + if (e instanceof FetchError) { 43 + await handleAuthError(e, userHandle) 44 + } 45 + return { success: false, error: e as Error } 46 + } 47 + } 48 + 49 + /** 50 + * Toggle like status for a package 51 + */ 52 + export async function togglePackageLike( 53 + packageName: string, 54 + currentlyLiked: boolean, 55 + userHandle?: string | null, 56 + ): Promise<LikeResult> { 57 + return currentlyLiked 58 + ? unlikePackage(packageName, userHandle) 59 + : likePackage(packageName, userHandle) 60 + }
+6
modules/cache.ts
··· 1 + import process from 'node:process' 1 2 import { defineNuxtModule } from 'nuxt/kit' 2 3 import { provider } from 'std-env' 3 4 ··· 26 27 nitroConfig.storage[FETCH_CACHE_STORAGE_BASE] = { 27 28 ...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE], 28 29 driver: 'vercel-runtime-cache', 30 + } 31 + 32 + const env = process.env.VERCEL_ENV 33 + nitroConfig.storage.atproto = { 34 + driver: env === 'production' ? 'vercel-kv' : 'vercel-runtime-cache', 29 35 } 30 36 }) 31 37 },
+10
nuxt.config.ts
··· 92 92 // never cache 93 93 '/search': { isr: false, cache: false }, 94 94 '/api/auth/**': { isr: false, cache: false }, 95 + '/api/social/**': { isr: false, cache: false }, 95 96 // infinite cache (versioned - doesn't change) 96 97 '/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 97 98 '/package-docs/:pkg/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, ··· 150 151 'fetch-cache': { 151 152 driver: 'fsLite', 152 153 base: './.cache/fetch', 154 + }, 155 + 'atproto': { 156 + driver: 'fsLite', 157 + base: './.cache/atproto', 153 158 }, 154 159 }, 155 160 typescript: { ··· 253 258 'virtua/vue', 254 259 'semver', 255 260 'validate-npm-package-name', 261 + '@atproto/lex', 262 + '@atproto/lex-data', 263 + '@atproto/lex-json', 264 + '@atproto/lex-schema', 265 + '@atproto/lex-client', 256 266 ], 257 267 }, 258 268 },
+3 -1
server/api/auth/atproto.get.ts
··· 1 1 import { Agent } from '@atproto/api' 2 2 import { NodeOAuthClient } from '@atproto/oauth-client-node' 3 3 import { createError, getQuery, sendRedirect } from 'h3' 4 + import { getOAuthLock } from '#server/utils/atproto/lock' 4 5 import { useOAuthStorage } from '#server/utils/atproto/storage' 5 6 import { SLINGSHOT_HOST } from '#shared/utils/constants' 6 7 import { useServerSession } from '#server/utils/server-session' ··· 11 12 if (!config.sessionPassword) { 12 13 throw createError({ 13 14 status: 500, 14 - message: 'NUXT_SESSION_PASSWORD not set', 15 + message: UNSET_NUXT_SESSION_PASSWORD, 15 16 }) 16 17 } 17 18 ··· 24 25 stateStore, 25 26 sessionStore, 26 27 clientMetadata, 28 + requestLock: getOAuthLock(), 27 29 }) 28 30 29 31 if (!query.code) {
+41
server/api/social/like.delete.ts
··· 1 + import * as v from 'valibot' 2 + import { Client } from '@atproto/lex' 3 + import * as dev from '#shared/types/lexicons/dev' 4 + import { PackageLikeBodySchema } from '#shared/schemas/social' 5 + import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth' 6 + 7 + export default eventHandlerWithOAuthSession(async (event, oAuthSession) => { 8 + const loggedInUsersDid = oAuthSession?.did.toString() 9 + 10 + if (!oAuthSession || !loggedInUsersDid) { 11 + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) 12 + } 13 + 14 + //Checks if the user has a scope to like packages 15 + await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE) 16 + 17 + const body = v.parse(PackageLikeBodySchema, await readBody(event)) 18 + 19 + const likesUtil = new PackageLikesUtils() 20 + 21 + const getTheUsersLikedRecord = await likesUtil.getTheUsersLikedRecord( 22 + body.packageName, 23 + loggedInUsersDid, 24 + ) 25 + 26 + if (getTheUsersLikedRecord) { 27 + const client = new Client(oAuthSession) 28 + 29 + await client.delete(dev.npmx.feed.like, { 30 + rkey: getTheUsersLikedRecord.rkey, 31 + }) 32 + const result = await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid) 33 + return result 34 + } 35 + 36 + console.warn( 37 + `User ${loggedInUsersDid} tried to unlike a package ${body.packageName} but it was not liked by them.`, 38 + ) 39 + 40 + return await likesUtil.getLikes(body.packageName, loggedInUsersDid) 41 + })
+46
server/api/social/like.post.ts
··· 1 + import * as v from 'valibot' 2 + import { Client } from '@atproto/lex' 3 + import * as dev from '#shared/types/lexicons/dev' 4 + import type { UriString } from '@atproto/lex' 5 + import { LIKES_SCOPE } from '#shared/utils/constants' 6 + import { PackageLikeBodySchema } from '#shared/schemas/social' 7 + import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth' 8 + 9 + export default eventHandlerWithOAuthSession(async (event, oAuthSession) => { 10 + const loggedInUsersDid = oAuthSession?.did.toString() 11 + 12 + if (!oAuthSession || !loggedInUsersDid) { 13 + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) 14 + } 15 + 16 + //Checks if the user has a scope to like packages 17 + await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE) 18 + 19 + const body = v.parse(PackageLikeBodySchema, await readBody(event)) 20 + 21 + const likesUtil = new PackageLikesUtils() 22 + 23 + // Checks to see if the user has liked the package already 24 + const likesResult = await likesUtil.getLikes(body.packageName, loggedInUsersDid) 25 + if (likesResult.userHasLiked) { 26 + return likesResult 27 + } 28 + 29 + const subjectRef = PACKAGE_SUBJECT_REF(body.packageName) 30 + const client = new Client(oAuthSession) 31 + 32 + const like = dev.npmx.feed.like.$build({ 33 + createdAt: new Date().toISOString(), 34 + subjectRef: subjectRef as UriString, 35 + }) 36 + 37 + const result = await client.create(dev.npmx.feed.like, like) 38 + if (!result) { 39 + throw createError({ 40 + status: 500, 41 + message: 'Failed to create a like', 42 + }) 43 + } 44 + 45 + return await likesUtil.likeAPackageAndReturnLikes(body.packageName, loggedInUsersDid, result.uri) 46 + })
+12
server/api/social/likes/[...pkg].get.ts
··· 1 + export default eventHandlerWithOAuthSession(async (event, oAuthSession, _) => { 2 + const packageName = getRouterParam(event, 'pkg') 3 + if (!packageName) { 4 + throw createError({ 5 + status: 400, 6 + message: 'package name not provided', 7 + }) 8 + } 9 + 10 + const likesUtil = new PackageLikesUtils() 11 + return await likesUtil.getLikes(packageName, oAuthSession?.did.toString()) 12 + })
+20 -3
server/utils/atproto/oauth.ts
··· 4 4 import { parse } from 'valibot' 5 5 import { getOAuthLock } from '#server/utils/atproto/lock' 6 6 import { useOAuthStorage } from '#server/utils/atproto/storage' 7 + import { LIKES_SCOPE } from '#shared/utils/constants' 7 8 import { OAuthMetadataSchema } from '#shared/schemas/oauth' 8 9 // @ts-expect-error virtual file from oauth module 9 10 import { clientUri } from '#oauth/config' 10 - import { useServerSession } from '#server/utils/server-session' 11 - // TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes 12 - export const scope = 'atproto' 11 + // TODO: If you add writing a new record you will need to add a scope for it 12 + export const scope = `atproto ${LIKES_SCOPE}` 13 13 14 14 export function getOauthClientMetadata() { 15 15 const dev = import.meta.dev ··· 59 59 60 60 // restore using the subject 61 61 return await client.restore(currentSession.tokenSet.sub) 62 + } 63 + 64 + /** 65 + * Throws if the logged in OAuth Session does not have the required scopes. 66 + * As we add new scopes we need to check if the client has the ability to use it. 67 + * If not need to let the client know to redirect the user to the PDS to upgrade their scopes. 68 + * @param oAuthSession - The current OAuth session from the event 69 + * @param requiredScopes - The required scope you are checking if you can use 70 + */ 71 + export async function throwOnMissingOAuthScope(oAuthSession: OAuthSession, requiredScopes: string) { 72 + const tokenInfo = await oAuthSession.getTokenInfo() 73 + if (!tokenInfo.scope.includes(requiredScopes)) { 74 + throw createError({ 75 + status: 403, 76 + message: ERROR_NEED_REAUTH, 77 + }) 78 + } 62 79 } 63 80 64 81 export function eventHandlerWithOAuthSession<T extends EventHandlerRequest, D>(
+241
server/utils/atproto/utils/likes.ts
··· 1 + import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs' 2 + import type { Backlink } from '#shared/utils/constellation' 3 + 4 + //Cache keys and helpers 5 + const CACHE_PREFIX = 'atproto-likes:' 6 + const CACHE_PACKAGE_TOTAL_KEY = (packageName: string) => `${CACHE_PREFIX}${packageName}:total` 7 + const CACHE_USER_LIKES_KEY = (packageName: string, did: string) => 8 + `${CACHE_PREFIX}${packageName}:users:${did}:liked` 9 + const CACHE_USERS_BACK_LINK = (packageName: string, did: string) => 10 + `${CACHE_PREFIX}${packageName}:users:${did}:backlink` 11 + 12 + const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5 13 + 14 + /** 15 + * Logic to handle liking, unliking, and seeing if a user has liked a package on npmx 16 + */ 17 + export class PackageLikesUtils { 18 + private readonly constellation: Constellation 19 + private readonly cache: CacheAdapter 20 + 21 + constructor() { 22 + this.constellation = new Constellation( 23 + // Passes in a fetch wrapped as cachedfetch since are already doing some heavy caching here 24 + async <T = unknown>( 25 + url: string, 26 + options: Parameters<typeof $fetch>[1] = {}, 27 + _ttl?: number, 28 + ): Promise<CachedFetchResult<T>> => { 29 + const data = (await $fetch<T>(url, options)) as T 30 + return { data, isStale: false, cachedAt: null } 31 + }, 32 + ) 33 + this.cache = getCacheAdapter('generic') 34 + } 35 + 36 + /** 37 + * Gets the true total count of likes for a npm package from the network 38 + * @param subjectRef 39 + * @returns 40 + */ 41 + private async constellationLikes(subjectRef: string) { 42 + const { data: totalLinks } = await this.constellation.getLinksDistinctDids( 43 + subjectRef, 44 + likeNsid, 45 + '.subjectRef', 46 + //Limit doesn't matter here since we are just counting the total likes 47 + 1, 48 + undefined, 49 + 0, 50 + ) 51 + return totalLinks.total 52 + } 53 + 54 + /** 55 + * Checks if the user has liked the npm package from the network 56 + * @param subjectRef 57 + * @param usersDid 58 + * @returns 59 + */ 60 + private async constellationUserHasLiked(subjectRef: string, usersDid: string) { 61 + const { data: userLikes } = await this.constellation.getBackLinks( 62 + subjectRef, 63 + likeNsid, 64 + 'subjectRef', 65 + //Limit doesn't matter here since we are just counting the total likes 66 + 1, 67 + undefined, 68 + false, 69 + [[usersDid]], 70 + 0, 71 + ) 72 + return userLikes.total > 0 73 + } 74 + 75 + /** 76 + * Gets the likes for a npm package on npmx. Tries a local cahce first, if not found uses constellation 77 + * @param packageName 78 + * @param usersDid 79 + * @returns 80 + */ 81 + async getLikes(packageName: string, usersDid?: string | undefined): Promise<PackageLikes> { 82 + //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 83 + const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) 84 + const subjectRef = PACKAGE_SUBJECT_REF(packageName) 85 + 86 + const cachedLikes = await this.cache.get<number>(totalLikesKey) 87 + let totalLikes = 0 88 + if (cachedLikes) { 89 + totalLikes = cachedLikes 90 + } else { 91 + totalLikes = await this.constellationLikes(subjectRef) 92 + await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE) 93 + } 94 + 95 + let userHasLiked = false 96 + if (usersDid) { 97 + const userCachedLike = await this.cache.get<boolean>( 98 + CACHE_USER_LIKES_KEY(packageName, usersDid), 99 + ) 100 + if (userCachedLike) { 101 + userHasLiked = userCachedLike 102 + } else { 103 + userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid) 104 + await this.cache.set( 105 + CACHE_USER_LIKES_KEY(packageName, usersDid), 106 + userHasLiked, 107 + CACHE_MAX_AGE, 108 + ) 109 + } 110 + } 111 + 112 + return { 113 + totalLikes: totalLikes, 114 + userHasLiked, 115 + } 116 + } 117 + 118 + /** 119 + * Gets the definite answer if the user has liked a npm package. Either from the cache or the network 120 + * @param packageName 121 + * @param usersDid 122 + * @returns 123 + */ 124 + async hasTheUserLikedThePackage(packageName: string, usersDid: string) { 125 + const cached = await this.cache.get<boolean>(CACHE_USER_LIKES_KEY(packageName, usersDid)) 126 + if (cached !== undefined) { 127 + return cached 128 + } 129 + const subjectRef = PACKAGE_SUBJECT_REF(packageName) 130 + 131 + const userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid) 132 + await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE) 133 + return userHasLiked 134 + } 135 + 136 + /** 137 + * 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 138 + * to the user's atproto repostiory 139 + * @param packageName 140 + * @param usersDid 141 + * @param atUri - The URI of the like record 142 + */ 143 + async likeAPackageAndReturnLikes( 144 + packageName: string, 145 + usersDid: string, 146 + atUri: string, 147 + ): Promise<PackageLikes> { 148 + const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) 149 + const subjectRef = PACKAGE_SUBJECT_REF(packageName) 150 + 151 + const splitAtUri = atUri.replace('at://', '').split('/') 152 + const collection = splitAtUri[1] 153 + const rkey = splitAtUri[2] 154 + 155 + if (!collection || !rkey) { 156 + throw new Error(`Invalid atUri given: ${atUri}`) 157 + } 158 + const backLink: Backlink = { 159 + did: usersDid, 160 + collection, 161 + rkey, 162 + } 163 + 164 + // We store the backlink incase a user is liking and unlikign rapidly. constellation takes a few seconds to capture the backlink 165 + const usersBackLinkKey = CACHE_USERS_BACK_LINK(packageName, usersDid) 166 + await this.cache.set(usersBackLinkKey, backLink, CACHE_MAX_AGE) 167 + 168 + let totalLikes = await this.cache.get<number>(totalLikesKey) 169 + if (!totalLikes) { 170 + totalLikes = await this.constellationLikes(subjectRef) 171 + totalLikes = totalLikes + 1 172 + await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE) 173 + } 174 + // We already know the user has not liked the package before so set in the cache 175 + await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE) 176 + return { 177 + totalLikes: totalLikes, 178 + userHasLiked: true, 179 + } 180 + } 181 + 182 + /** 183 + * We need to get the record the user has that they liked the package 184 + * @param packageName 185 + * @param usersDid 186 + * @returns 187 + */ 188 + async getTheUsersLikedRecord( 189 + packageName: string, 190 + usersDid: string, 191 + ): Promise<Backlink | undefined> { 192 + const usersBackLinkKey = CACHE_USERS_BACK_LINK(packageName, usersDid) 193 + const backLink = await this.cache.get<Backlink>(usersBackLinkKey) 194 + if (backLink) { 195 + return backLink 196 + } 197 + 198 + const subjectRef = PACKAGE_SUBJECT_REF(packageName) 199 + const { data: userLikes } = await this.constellation.getBackLinks( 200 + subjectRef, 201 + likeNsid, 202 + 'subjectRef', 203 + //Limit doesn't matter here since we are just counting the total likes 204 + 1, 205 + undefined, 206 + false, 207 + [[usersDid]], 208 + 0, 209 + ) 210 + if (userLikes.total > 0 && userLikes.records.length > 0) { 211 + return userLikes.records[0] 212 + } 213 + } 214 + 215 + /** 216 + * At this point you should have checked if the user had a record for the package on the network and removed it before updating the cache 217 + * @param packageName 218 + * @param usersDid 219 + * @returns 220 + */ 221 + async unlikeAPackageAndReturnLikes(packageName: string, usersDid: string): Promise<PackageLikes> { 222 + const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) 223 + const subjectRef = PACKAGE_SUBJECT_REF(packageName) 224 + 225 + let totalLikes = await this.cache.get<number>(totalLikesKey) 226 + if (!totalLikes) { 227 + totalLikes = await this.constellationLikes(subjectRef) 228 + } 229 + totalLikes = Math.max(totalLikes - 1, 0) 230 + await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE) 231 + 232 + //Clean up 233 + await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), false, CACHE_MAX_AGE) 234 + await this.cache.delete(CACHE_USERS_BACK_LINK(packageName, usersDid)) 235 + 236 + return { 237 + totalLikes: totalLikes, 238 + userHasLiked: false, 239 + } 240 + } 241 + }
+14
server/utils/cache/adapter.ts
··· 1 + import { Redis } from '@upstash/redis' 2 + 3 + export function getCacheAdapter(prefix: string): CacheAdapter { 4 + const config = useRuntimeConfig() 5 + 6 + if (!import.meta.dev && config.upstash?.redisRestUrl && config.upstash?.redisRestToken) { 7 + const redis = new Redis({ 8 + url: config.upstash.redisRestUrl, 9 + token: config.upstash.redisRestToken, 10 + }) 11 + return new RedisCacheAdapter(redis, prefix) 12 + } 13 + return new LocalCacheAdapter() 14 + }
+45
server/utils/cache/local.ts
··· 1 + /** 2 + * Local cache data entry 3 + */ 4 + interface LocalCachedEntry<T = unknown> { 5 + value: T 6 + ttl?: number 7 + cachedAt: number 8 + } 9 + 10 + /** 11 + * Checks to see if a cache entry is stale locally 12 + * @param entry - The entry from the locla cache 13 + * @returns 14 + */ 15 + function isCacheEntryStale(entry: LocalCachedEntry): boolean { 16 + if (!entry.ttl) return false 17 + const now = Date.now() 18 + const expiresAt = entry.cachedAt + entry.ttl * 1000 19 + return now > expiresAt 20 + } 21 + 22 + /** 23 + * Local implmentation of a cache to be used during development 24 + */ 25 + export class LocalCacheAdapter implements CacheAdapter { 26 + private readonly storage = useStorage('atproto:generic') 27 + 28 + async get<T>(key: string): Promise<T | undefined> { 29 + const result = await this.storage.getItem<LocalCachedEntry<T>>(key) 30 + if (!result) return 31 + if (isCacheEntryStale(result)) { 32 + await this.storage.removeItem(key) 33 + return 34 + } 35 + return result.value 36 + } 37 + 38 + async set<T>(key: string, value: T, ttl?: number): Promise<void> { 39 + await this.storage.setItem(key, { value, ttl, cachedAt: Date.now() }) 40 + } 41 + 42 + async delete(key: string): Promise<void> { 43 + await this.storage.removeItem(key) 44 + } 45 + }
+39
server/utils/cache/redis.ts
··· 1 + import type { Redis } from '@upstash/redis' 2 + 3 + /** 4 + * Redis cache storage with TTL handled by redis for use in production 5 + */ 6 + export class RedisCacheAdapter implements CacheAdapter { 7 + private readonly redis: Redis 8 + private readonly prefix: string 9 + 10 + formatKey(key: string): string { 11 + return `${this.prefix}:${key}` 12 + } 13 + 14 + constructor(redis: Redis, prefix: string) { 15 + this.redis = redis 16 + this.prefix = prefix 17 + } 18 + 19 + async get<T>(key: string): Promise<T | undefined> { 20 + const formattedKey = this.formatKey(key) 21 + const value = await this.redis.get<T>(formattedKey) 22 + if (!value) return 23 + return value 24 + } 25 + 26 + async set<T>(key: string, value: T, ttl?: number): Promise<void> { 27 + const formattedKey = this.formatKey(key) 28 + if (ttl) { 29 + await this.redis.setex(formattedKey, ttl, value) 30 + } else { 31 + await this.redis.set(formattedKey, value) 32 + } 33 + } 34 + 35 + async delete(key: string): Promise<void> { 36 + const formattedKey = this.formatKey(key) 37 + await this.redis.del(formattedKey) 38 + } 39 + }
+8
server/utils/cache/shared.ts
··· 1 + /** 2 + * Generic cache adapter to allow using a local cache during development and redis in production 3 + */ 4 + export interface CacheAdapter { 5 + get<T>(key: string): Promise<T | undefined> 6 + set<T>(key: string, value: T, ttl?: number): Promise<void> 7 + delete(key: string): Promise<void> 8 + }
+11
shared/schemas/social.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageNameSchema } from './package' 3 + 4 + /** 5 + * Schema for liking/unliking a package 6 + */ 7 + export const PackageLikeBodySchema = v.object({ 8 + packageName: PackageNameSchema, 9 + }) 10 + 11 + export type PackageLikeBody = v.InferOutput<typeof PackageLikeBodySchema>
+9
shared/types/social.ts
··· 1 + /** 2 + * Likes for a npm package on npmx 3 + */ 4 + export type PackageLikes = { 5 + // The total likes found for the package 6 + totalLikes: number 7 + // If the logged in user has liked the package, false if not logged in 8 + userHasLiked: boolean 9 + }
+10
shared/utils/constants.ts
··· 1 + import * as dev from '#shared/types/lexicons/dev' 2 + 1 3 // Duration 2 4 export const CACHE_MAX_AGE_ONE_MINUTE = 60 3 5 export const CACHE_MAX_AGE_FIVE_MINUTES = 60 * 5 ··· 25 27 export const ERROR_GRAVATAR_FETCH_FAILED = 'Failed to fetch Gravatar profile.' 26 28 /** @public */ 27 29 export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible." 30 + export const ERROR_NEED_REAUTH = 'User needs to reauthenticate' 28 31 29 32 // microcosm services 30 33 export const CONSTELLATION_HOST = 'constellation.microcosm.blue' 31 34 export const SLINGSHOT_HOST = 'slingshot.microcosm.blue' 35 + 36 + // ATProtocol 37 + // Refrences used to link packages to things that are not inherently atproto 38 + export const PACKAGE_SUBJECT_REF = (packageName: string) => 39 + `https://npmx.dev/package/${packageName}` 40 + // OAuth scopes as we add new ones we need to check these on certain actions. If not redirect the user to login again to upgrade the scopes 41 + export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}` 32 42 33 43 // Theming 34 44 export const ACCENT_COLORS = {