diff --git a/server/package-lock.json b/server/package-lock.json index f3eea12..ecb68da 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,7 +13,8 @@ "cors": "^2.8.5", "crypto": "^1.0.1", "express": "^4.18.2", - "midi-writer-js": "^3.1.1" + "midi-writer-js": "^3.1.1", + "redis": "^5.10.0" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -465,6 +466,66 @@ "node": ">=18" } }, + "node_modules/@redis/bloom": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz", + "integrity": "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/client": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", + "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@redis/json": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.10.0.tgz", + "integrity": "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/search": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.10.0.tgz", + "integrity": "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/time-series": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.10.0.tgz", + "integrity": "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, "node_modules/@tonaljs/midi": { "version": "4.10.2", "resolved": "https://registry.npmjs.org/@tonaljs/midi/-/midi-4.10.2.tgz", @@ -779,6 +840,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1624,6 +1694,22 @@ "node": ">=0.10.0" } }, + "node_modules/redis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz", + "integrity": "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.10.0", + "@redis/client": "5.10.0", + "@redis/json": "5.10.0", + "@redis/search": "5.10.0", + "@redis/time-series": "5.10.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", diff --git a/server/package.json b/server/package.json index ec97b5a..aba23cf 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,8 @@ "cors": "^2.8.5", "crypto": "^1.0.1", "express": "^4.18.2", - "midi-writer-js": "^3.1.1" + "midi-writer-js": "^3.1.1", + "redis": "^5.10.0" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/server/src/server.ts b/server/src/server.ts index 3cee3eb..6431cd4 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -2,6 +2,7 @@ import express from 'express'; import cors from 'cors'; import crypto from 'node:crypto'; import { kv } from '@vercel/kv'; +import { createClient } from 'redis'; import { MIDISearchService } from './services/MIDISearchService.js'; import { MIDIFetchService } from './services/MIDIFetchService.js'; import { MIDIParseService } from './services/MIDIParseService.js'; @@ -35,6 +36,28 @@ type SharePayload = const localShareStore = new Map(); const isVercel = Boolean(process.env.VERCEL); const hasKvEnv = Boolean(process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN); +const redisUrl = process.env.REDIS_URL; +const hasRedisEnv = Boolean(redisUrl); + +let redisClient: ReturnType | null = null; +let redisConnectPromise: Promise | null = null; + +async function getRedis(): Promise> { + if (!redisUrl) throw new Error('REDIS_URL is not configured for this project.'); + if (!redisClient) { + redisClient = createClient({ url: redisUrl }); + redisClient.on('error', (err) => console.error('Redis error:', err)); + } + if (!redisClient.isOpen) { + if (!redisConnectPromise) { + redisConnectPromise = redisClient.connect().then(() => {}).finally(() => { + redisConnectPromise = null; + }); + } + await redisConnectPromise; + } + return redisClient; +} function base62(bytes: Uint8Array): string { const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; @@ -52,36 +75,43 @@ async function shareSet(code: string, payload: SharePayload): Promise { const key = `share:${code}`; // 30 days TTL const exSec = 60 * 60 * 24 * 30; - if (isVercel && !hasKvEnv) { - throw new Error('Vercel KV is not configured for this project.'); + if (isVercel && !hasRedisEnv && !hasKvEnv) { + throw new Error('No storage configured (need REDIS_URL or Vercel KV env vars).'); } - try { - if (hasKvEnv) { - await kv.set(key, payload, { ex: exSec }); - return; - } - } catch { - // fall through to local store (dev only) + // Prefer Redis integration if present (your project has REDIS_URL). + if (hasRedisEnv) { + const r = await getRedis(); + await r.set(key, JSON.stringify(payload), { EX: exSec }); + return; } - // Local dev: best-effort in-memory store. + // Fallback: Vercel KV REST (Upstash) + if (hasKvEnv) { + await kv.set(key, payload, { ex: exSec }); + return; + } + + // Local dev only: best-effort in-memory store. localShareStore.set(code, payload); } async function shareGet(code: string): Promise { const key = `share:${code}`; - if (isVercel && !hasKvEnv) { - throw new Error('Vercel KV is not configured for this project.'); + if (isVercel && !hasRedisEnv && !hasKvEnv) { + throw new Error('No storage configured (need REDIS_URL or Vercel KV env vars).'); } - try { - if (hasKvEnv) { - const val = await kv.get(key); - return val ?? null; - } - } catch { - // fall through to local store (dev only) + if (hasRedisEnv) { + const r = await getRedis(); + const val = await r.get(key); + if (!val) return null; + return JSON.parse(val) as SharePayload; + } + + if (hasKvEnv) { + const val = await kv.get(key); + return val ?? null; } return localShareStore.get(code) ?? null;