diff --git a/config/storages.ts b/config/storages.ts new file mode 100644 index 0000000000..f00152c890 --- /dev/null +++ b/config/storages.ts @@ -0,0 +1,92 @@ +import type { StorageMounts } from 'nitropack' + +// https://nuxt.com/docs/api/configuration/nuxt-config +declare global { + namespace NodeJS { + interface ProcessEnv { + /** Preset used to build Nitro (provided manually). */ + NITRO_PRESET?: string + + /** Cloudflare KV via binding: name of the binding. */ + CF_KV_BINDING_CACHE?: string + + /** Vercel KV: token. */ + KV_REST_API_TOKEN?: string + /** Vercel KV: API URL. */ + KV_REST_API_URL?: string + /** + * Vercel KV: base name for cache KV. + * @default 'cache' + */ + VERCEL_KV_CACHE_BASE?: string + + /** Cache storage option. */ + CACHE_STORAGE_OPTION?: string + } + } +} + +/** + * Checks that all environment variables are defined. + * @param vars Variables to check. + * @returns All missing variables. + */ +function getMissingVars(vars: string[]) { + return vars.filter((varName) => !process.env[varName]) +} + +/** + * Returns Nitro storage mounts or nothing. + */ +function getCacheStorageMount(): StorageMounts[string] | undefined { + switch (process.env.CACHE_STORAGE_OPTION) { + case 'cloudflare-kv': { + if (process.env.CF_KV_BINDING_CACHE) { + return { + driver: '~/server/storage/cached-cloudflare-kv-binding', + binding: process.env.CF_KV_BINDING_CACHE, + } + } + + console.warn( + 'You wanted to use `cloudflare-kv` cache store option, however you have not provided `CF_KV_BINDING_CACHE` environment variable. The cache will use in-memory storage that is not persistent in workers.' + ) + + break + } + case 'vercel-kv': { + const missingVars = getMissingVars(['KV_REST_API_TOKEN', 'KV_REST_API_URL']) + + if (!missingVars.length) { + return { + driver: '~/server/storage/cached-vercel-kv', + base: process.env.VERCEL_KV_CACHE_BASE || 'cache', + url: process.env.KV_REST_API_URL, + token: process.env.KV_REST_API_TOKEN, + env: false, + } + } + + console.log( + `You wanted to use \`vercel-kv\` cache store option, however you have not provided ${missingVars + .map((varName) => `\`${varName}\``) + .join( + ', ' + )} environment variable. The cache will use in-memory storage taht is not persistent in serverless functions.` + ) + + break + } + } + + return undefined +} + +export function getStorageMounts(): StorageMounts | undefined { + let mounts: StorageMounts | undefined + + const cacheMount = getCacheStorageMount() + if (cacheMount != null) (mounts ??= {}).cache = cacheMount + + return mounts +} diff --git a/nuxt.config.ts b/nuxt.config.ts index 4a420a154d..c0938e0be4 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -5,6 +5,7 @@ import { resolve, basename } from 'pathe' import { defineNuxtConfig } from 'nuxt/config' import { globIterate } from 'glob' import { match as matchLocale } from '@formatjs/intl-localematcher' +import { getStorageMounts } from './config/storages.ts' const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/' const STAGING_ARIADNE_URL = 'https://staging-ariadne.modrinth.com/v1/' @@ -232,6 +233,7 @@ export default defineNuxtConfig({ parserless: 'only-prod', }, nitro: { + storage: getStorageMounts(), moduleSideEffects: ['@vintl/compact-number/locale-data'], }, }) diff --git a/package.json b/package.json index 2c50ce0947..9c43cb27b8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/node": "^20.1.0", "@typescript-eslint/eslint-plugin": "^5.59.8", "@typescript-eslint/parser": "^5.59.8", + "@vercel/kv": "^0.2.2", "@vintl/compact-number": "^2.0.4", "@vintl/how-ago": "^2.0.1", "@vintl/nuxt": "^1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab9d42d377..c671fcc3db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ devDependencies: '@typescript-eslint/parser': specifier: ^5.59.8 version: 5.59.8(eslint@8.41.0)(typescript@5.0.4) + '@vercel/kv': + specifier: ^0.2.2 + version: 0.2.2 '@vintl/compact-number': specifier: ^2.0.4 version: 2.0.4(@formatjs/intl@2.7.2) @@ -86,7 +89,7 @@ devDependencies: version: 10.2.7 nuxt: specifier: ^3.5.3 - version: 3.5.3(@types/node@20.1.0)(eslint@8.41.0)(sass@1.58.0)(typescript@5.0.4)(vue-tsc@1.6.5) + version: 3.5.3(@types/node@20.1.0)(@vercel/kv@0.2.2)(eslint@8.41.0)(sass@1.58.0)(typescript@5.0.4)(vue-tsc@1.6.5) prettier: specifier: ^2.8.8 version: 2.8.8 @@ -1747,6 +1750,23 @@ packages: vue: 3.3.4 dev: true + /@upstash/redis@1.21.0: + resolution: {integrity: sha512-c6M+cl0LOgGK/7Gp6ooMkIZ1IDAJs8zFR+REPkoSkAq38o7CWFX5FYwYEqGZ6wJpUGBuEOr/7hTmippXGgL25A==} + dependencies: + isomorphic-fetch: 3.0.0 + transitivePeerDependencies: + - encoding + dev: true + + /@vercel/kv@0.2.2: + resolution: {integrity: sha512-mqnQOB6bkp4h5eObxfLNIlhlVqOGSH8cWOlC5pDVWTjX3zL8dETO1ZBl6M74HBmeBjbD5+J7wDJklRigY6UNKw==} + engines: {node: '>=14.6'} + dependencies: + '@upstash/redis': 1.21.0 + transitivePeerDependencies: + - encoding + dev: true + /@vercel/nft@0.22.6: resolution: {integrity: sha512-gTsFnnT4mGxodr4AUlW3/urY+8JKKB452LwF3m477RFUJTAaDmcz2JqFuInzvdybYIeyIv1sSONEJxsxnbQ5JQ==} engines: {node: '>=14'} @@ -4575,6 +4595,15 @@ packages: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true + /isomorphic-fetch@3.0.0: + resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} + dependencies: + node-fetch: 2.6.12 + whatwg-fetch: 3.6.2 + transitivePeerDependencies: + - encoding + dev: true + /jackspeak@2.2.1: resolution: {integrity: sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==} engines: {node: '>=14'} @@ -5049,7 +5078,7 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /nitropack@2.5.2: + /nitropack@2.5.2(@vercel/kv@0.2.2): resolution: {integrity: sha512-hXEHY9NJmOOETFFTPCBB9PB0+txoAbU/fB2ovUF6UMRo4ucQZztYnZdX+YSxa6FVz6eONvcxXvf9/9s6t08KWw==} engines: {node: ^14.16.0 || ^16.11.0 || >=17.0.0} hasBin: true @@ -5117,7 +5146,7 @@ packages: uncrypto: 0.1.3 unenv: 1.5.1 unimport: 3.0.12(rollup@3.26.0) - unstorage: 1.7.0 + unstorage: 1.7.0(@vercel/kv@0.2.2) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -5241,7 +5270,7 @@ packages: fsevents: 2.3.2 dev: true - /nuxt@3.5.3(@types/node@20.1.0)(eslint@8.41.0)(sass@1.58.0)(typescript@5.0.4)(vue-tsc@1.6.5): + /nuxt@3.5.3(@types/node@20.1.0)(@vercel/kv@0.2.2)(eslint@8.41.0)(sass@1.58.0)(typescript@5.0.4)(vue-tsc@1.6.5): resolution: {integrity: sha512-fG39BZ5N5ATtmx2vuxN8APQPSlSsCDpfkJ0k581gMc7eFztqrBzPncZX5w3RQLW7AiGBE2yYEfqiwC6AVODBBg==} engines: {node: ^14.18.0 || >=16.10.0} hasBin: true @@ -5280,7 +5309,7 @@ packages: local-pkg: 0.4.3 magic-string: 0.30.0 mlly: 1.4.0 - nitropack: 2.5.2 + nitropack: 2.5.2(@vercel/kv@0.2.2) nuxi: 3.5.3 nypm: 0.2.2 ofetch: 1.1.1 @@ -6897,7 +6926,7 @@ packages: webpack-virtual-modules: 0.5.0 dev: true - /unstorage@1.7.0: + /unstorage@1.7.0(@vercel/kv@0.2.2): resolution: {integrity: sha512-f78UtR4HyUGWuET35iNPdKMvCh9YPQpC7WvkGpP6XiLlolT/9wjyAICYN9AMD/tlB8ZdOqWQHZn+j7mXcTSO4w==} peerDependencies: '@azure/app-configuration': ^1.4.1 @@ -6929,6 +6958,7 @@ packages: '@vercel/kv': optional: true dependencies: + '@vercel/kv': 0.2.2 anymatch: 3.1.3 chokidar: 3.5.3 destr: 2.0.0 @@ -7262,6 +7292,10 @@ packages: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} dev: true + /whatwg-fetch@3.6.2: + resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} + dev: true + /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: diff --git a/server/storage/cached-cloudflare-kv-binding.ts b/server/storage/cached-cloudflare-kv-binding.ts new file mode 100644 index 0000000000..b961fb199f --- /dev/null +++ b/server/storage/cached-cloudflare-kv-binding.ts @@ -0,0 +1,7 @@ +import cloudflareKVStorage, { KVOptions } from 'unstorage/drivers/cloudflare-kv-binding' +import { Driver } from 'unstorage' +import cachedDriver from './cached.ts' + +export default function cachedVercelKV(opts: KVOptions): Driver { + return cachedDriver({ driver: cloudflareKVStorage(opts) }) +} diff --git a/server/storage/cached-vercel-kv.ts b/server/storage/cached-vercel-kv.ts new file mode 100644 index 0000000000..c6371443dc --- /dev/null +++ b/server/storage/cached-vercel-kv.ts @@ -0,0 +1,7 @@ +import vercelStorage, { VercelKVOptions } from 'unstorage/drivers/vercel-kv' +import { Driver } from 'unstorage' +import cachedDriver from './cached.ts' + +export default function cachedVercelKV(opts: VercelKVOptions): Driver { + return cachedDriver({ driver: vercelStorage(opts) }) +} diff --git a/server/storage/cached.ts b/server/storage/cached.ts new file mode 100644 index 0000000000..21491568f8 --- /dev/null +++ b/server/storage/cached.ts @@ -0,0 +1,31 @@ +import { Driver } from 'unstorage' +import memoryDriver from 'unstorage/drivers/memory' + +export interface CachedOptions { + driver: Driver +} + +export default function cached(options: CachedOptions): Driver { + const { driver } = options + const memory = memoryDriver() as Driver + return { + ...driver, + name: driver.name ? `cached:${driver.name}` : `cached`, + options, + async hasItem(key) { + return (await memory.hasItem(key, {})) || (await driver.hasItem(key, {})) + }, + async getItem(key) { + const memoryLookup = await memory.getItem(key) + if (memoryLookup !== null) return memoryLookup + + const lookup = await driver.getItem(key) + memory.setItem!(key, lookup as any, {}) + return lookup + }, + async setItem(key, value) { + memory.setItem!(key, value, {}) + await driver.setItem?.(key, value, {}) + }, + } +}