diff --git a/.changeset/shy-poems-follow.md b/.changeset/shy-poems-follow.md new file mode 100644 index 0000000000..049323b177 --- /dev/null +++ b/.changeset/shy-poems-follow.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Simplify TTL handling for middleware cache and remove SWR-like behavior, preferring long TTLs instead. Introduce STORE_STATUS_CACHE_TTL and ROUTE_CACHE_TTL diff --git a/.changeset/violet-cups-carry.md b/.changeset/violet-cups-carry.md new file mode 100644 index 0000000000..0426c1c73b --- /dev/null +++ b/.changeset/violet-cups-carry.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Implement Vercel Runtime Cache as a replacement for KV products for middleware caching diff --git a/core/lib/fetch-cache/README.md b/core/lib/fetch-cache/README.md new file mode 100644 index 0000000000..647b807889 --- /dev/null +++ b/core/lib/fetch-cache/README.md @@ -0,0 +1,291 @@ +# Fetch Cache System + +A 2-layer caching system designed to work around Next.js middleware limitations where the normal `fetch()` Data Cache is not available. + +## Overview + +This system provides a drop-in replacement for data fetching in Next.js middleware with automatic TTL-based caching. It uses a memory-first approach with configurable backend storage. + +## Architecture + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Request │───▶│ Memory Cache │───▶│ Backend │ +└─────────────┘ └──────────────┘ │ Storage │ + │ └─────────────┘ + ▼ + ┌──────────────┐ + │ Fresh Fetch │ + └──────────────┘ +``` + +**2-Layer Strategy:** + +1. **Memory Cache** (L1): Fast, in-memory LRU cache with TTL support +2. **Backend Storage** (L2): Persistent storage (Vercel Runtime Cache, Redis, etc.) + +## Quick Start + +### Basic Usage + +```typescript +import { fetchWithTTLCache } from '~/lib/fetch-cache'; + +// Cache a single data fetch +const userData = await fetchWithTTLCache( + async () => { + const response = await fetch('/api/user/123'); + return response.json(); + }, + 'user:123', + { ttl: 300 }, // 5 minutes +); +``` + +### Batch Fetching + +```typescript +import { batchFetchWithTTLCache } from '~/lib/fetch-cache'; + +// Cache multiple related fetches efficiently +const [route, status] = await batchFetchWithTTLCache([ + { + fetcher: () => getRoute(pathname, channelId), + cacheKey: routeCacheKey(pathname, channelId), + options: { ttl: 86400 }, // 24 hours + }, + { + fetcher: () => getStoreStatus(channelId), + cacheKey: storeStatusCacheKey(channelId), + options: { ttl: 3600 }, // 1 hour + }, +]); +``` + +## Cache Key Management + +```typescript +import { cacheKey, routeCacheKey, storeStatusCacheKey } from '~/lib/fetch-cache/keys'; + +// Generic cache key with optional scope +const key1 = cacheKey('user-profile', 'channel-123'); // → "channel-123:user-profile" + +// Pre-built helpers for common use cases +const routeKey = routeCacheKey('/products', 'channel-123'); +const statusKey = storeStatusCacheKey('channel-123'); +``` + +## Backend Adapters + +The system automatically detects the best available backend: + +### 1. Cloudflare Workers (Future) + +```typescript +// Automatically detected in Cloudflare Workers environment +// Uses native Cache API for optimal performance +``` + +### 2. Vercel Edge Runtime + +```typescript +// Automatically detected when VERCEL=1 +// Uses @vercel/functions getCache() API +``` + +### 3. Upstash Redis + +```typescript +// Automatically detected when Redis env vars are present +// Requires: UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN +``` + +### 4. Memory Only (Fallback) + +```typescript +// Used when no other backend is available +// Memory cache only - no persistence +``` + +## Configuration + +### Environment Variables + +```bash +# Enable detailed logging (default: enabled in development) +FETCH_CACHE_LOGGER=true + +# TTL configuration (in seconds) +ROUTE_CACHE_TTL=86400 # 24 hours +STORE_STATUS_CACHE_TTL=3600 # 1 hour + +# Backend-specific configuration +VERCEL=1 # Auto-detected +UPSTASH_REDIS_REST_URL=https://... # Redis backend +UPSTASH_REDIS_REST_TOKEN=... # Redis backend +``` + +### Cache Options + +```typescript +interface FetchCacheOptions { + ttl?: number; // Time to live in seconds + tags?: string[]; // Cache tags for invalidation (backend dependent) + [key: string]: unknown; // Additional backend-specific options +} +``` + +## Logging + +When `FETCH_CACHE_LOGGER=true`, you'll see detailed operation logs: + +``` +[BigCommerce Fetch Cache] FETCH user:123 (Upstash Redis) - ✓ All from memory cache - Memory: 0.02ms, Total: 0.02ms +[BigCommerce Fetch Cache] BATCH_FETCH [route:/products, store-status] (Vercel Runtime Cache) - Memory: 1, Backend: 1 - Memory: 0.04ms, Backend: 1.23ms, Total: 1.27ms +[BigCommerce Fetch Cache] FETCH product:456 (Memory Only) - ✗ Fetch required: 1 - Backend: 45.67ms, Total: 45.71ms +``` + +**Log Format:** + +- `✓` = Cache hit +- `✗` = Cache miss (fresh fetch required) +- Backend shows which storage system is being used +- Timing breakdown shows memory vs backend vs total time + +## Examples + +### Middleware Usage (Before/After) + +**Before** (Complex manual cache management): + +```typescript +// Complex cache logic spread across multiple functions +let [route, status] = await kv.mget(kvKey(pathname, channelId), kvKey(STORE_STATUS_KEY, channelId)); + +if (!status) { + status = await fetchAndCacheStatus(channelId, event); +} + +if (!route) { + route = await fetchAndCacheRoute(pathname, channelId, event); +} +``` + +**After** (Clean, declarative): + +```typescript +// Simple, declarative fetch with automatic caching +const [route, status] = await batchFetchWithTTLCache([ + { + fetcher: () => getRoute(pathname, channelId), + cacheKey: routeCacheKey(pathname, channelId), + options: { ttl: ROUTE_CACHE_TTL }, + }, + { + fetcher: () => getStoreStatus(channelId), + cacheKey: storeStatusCacheKey(channelId), + options: { ttl: STORE_STATUS_CACHE_TTL }, + }, +]); +``` + +### Custom Cache Implementation + +```typescript +import { fetchCache } from '~/lib/fetch-cache'; + +// Direct cache access (advanced usage) +const cachedData = await fetchCache.get('user:123'); + +if (!cachedData) { + const freshData = await fetchUserData('123'); + await fetchCache.set('user:123', freshData, { ttl: 300 }); +} +``` + +## Performance Benefits + +- **Memory First**: Sub-millisecond cache hits for frequently accessed data +- **Batch Operations**: Optimized multi-key fetching reduces round trips +- **Platform Native**: Uses the best caching available for each environment +- **Fire-and-Forget**: Cache updates don't block the response +- **TTL Management**: Automatic expiration handling + +## Migration Guide + +### From Direct KV Usage + +```typescript +// Old KV approach +import { kv } from '~/lib/kv'; +const data = await kv.get('key'); +if (!data) { + const fresh = await fetchData(); + await kv.set('key', fresh, { ttl: 300 }); +} + +// New fetch cache approach +import { fetchWithTTLCache } from '~/lib/fetch-cache'; +const data = await fetchWithTTLCache(() => fetchData(), 'key', { ttl: 300 }); +``` + +### From Manual Cache Management + +The new system eliminates the need for manual cache checking, setting, and TTL management. Just wrap your data fetching function with `fetchWithTTLCache()` and the caching is handled automatically. + +## Extending the System + +### Adding New Backends + +```typescript +// Example: Custom database cache adapter +export class DatabaseCacheAdapter implements FetchCacheAdapter { + async get(cacheKey: string): Promise { + // Implement database get logic + } + + async set(cacheKey: string, data: T, options?: FetchCacheOptions): Promise { + // Implement database set logic with TTL + } + + async mget(...cacheKeys: string[]): Promise> { + // Implement batch get logic + } +} +``` + +Then add detection logic to `createFetchCacheAdapter()` in `index.ts`. + +## Troubleshooting + +### Cache Not Working + +1. Check if backend is properly configured (env vars) +2. Enable logging with `FETCH_CACHE_LOGGER=true` +3. Verify cache keys are consistent between set/get operations + +### Performance Issues + +1. Use batch fetching for multiple related operations +2. Choose appropriate TTL values (too short = frequent fetches, too long = stale data) +3. Monitor memory usage if using memory-only mode + +### Backend-Specific Issues + +**Vercel Runtime Cache:** + +- Only works in Vercel Edge Runtime +- Limited to 1MB per key +- Automatic cleanup based on usage + +**Upstash Redis:** + +- Check network connectivity +- Verify authentication tokens +- Monitor Redis memory usage + +**Memory Only:** + +- Limited by available memory +- No persistence across restarts +- Consider LRU cache size (default: 500 items) diff --git a/core/lib/fetch-cache/adapters/cloudflare-native.ts b/core/lib/fetch-cache/adapters/cloudflare-native.ts new file mode 100644 index 0000000000..5b2bc0f077 --- /dev/null +++ b/core/lib/fetch-cache/adapters/cloudflare-native.ts @@ -0,0 +1,88 @@ +import { FetchCacheAdapter, FetchCacheOptions } from '../types'; + +/** + * Cloudflare native cache adapter that uses the Cache API available in Cloudflare Workers. + * This demonstrates how platform-native caching can be used when available. + * + * Note: This is a future implementation for when running in Cloudflare Workers environment. + * Cloudflare Workers provide a Cache API that can be used directly. + */ +export class CloudflareNativeFetchCacheAdapter implements FetchCacheAdapter { + private cache?: Cache; + + private async getCache(): Promise { + if (!this.cache) { + // In Cloudflare Workers, caches.default is available + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.cache = (globalThis as any).caches?.default; + + if (!this.cache) { + throw new Error('Cloudflare Cache API not available'); + } + } + + return this.cache; + } + + private createCacheKey(key: string): string { + // Create a valid cache key for the Cache API + return `https://cache.internal/${encodeURIComponent(key)}`; + } + + async get(cacheKey: string): Promise { + try { + const cache = await this.getCache(); + const cacheUrl = this.createCacheKey(cacheKey); + + const response = await cache.match(cacheUrl); + + if (!response) { + return null; + } + + const data = await response.json(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return data as T; + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Cloudflare cache get failed for key ${cacheKey}:`, error); + return null; + } + } + + async mget(...cacheKeys: string[]): Promise> { + // For now, implement mget as parallel get operations + // Future optimization could use batch operations if available + const results = await Promise.all(cacheKeys.map((key) => this.get(key))); + + return results; + } + + async set(cacheKey: string, data: T, options: FetchCacheOptions = {}): Promise { + try { + const cache = await this.getCache(); + const cacheUrl = this.createCacheKey(cacheKey); + + // Create headers with TTL information + const headers = new Headers({ + 'Content-Type': 'application/json', + }); + + // Add cache control headers for TTL + if (options.ttl) { + headers.set('Cache-Control', `max-age=${options.ttl}`); + } + + // Create response to store in cache + const response = new Response(JSON.stringify(data), { headers }); + + await cache.put(cacheUrl, response); + + return data; + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Cloudflare cache set failed for key ${cacheKey}:`, error); + return null; + } + } +} diff --git a/core/lib/fetch-cache/adapters/memory.ts b/core/lib/fetch-cache/adapters/memory.ts new file mode 100644 index 0000000000..f6ac33f299 --- /dev/null +++ b/core/lib/fetch-cache/adapters/memory.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/require-await */ +import { LRUCache } from 'lru-cache'; + +import { FetchCacheAdapter, FetchCacheOptions } from '../types'; + +interface CacheEntry { + value: unknown; + expiresAt: number; +} + +export class MemoryFetchCacheAdapter implements FetchCacheAdapter { + private cache = new LRUCache({ + max: 500, + }); + + async get(cacheKey: string): Promise { + const entry = this.cache.get(cacheKey); + + if (!entry) { + return null; + } + + // Check if expired + if (entry.expiresAt < Date.now()) { + this.cache.delete(cacheKey); // Clean up expired entry + return null; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return entry.value as T; + } + + async mget(...cacheKeys: string[]): Promise> { + const results = cacheKeys.map((key) => { + const entry = this.cache.get(key); + + if (!entry) { + return null; + } + + // Check if expired + if (entry.expiresAt < Date.now()) { + this.cache.delete(key); // Clean up expired entry + return null; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return entry.value as T; + }); + + return results; + } + + async set(cacheKey: string, data: T, options: FetchCacheOptions = {}): Promise { + this.cache.set(cacheKey, { + value: data, + expiresAt: options.ttl ? Date.now() + options.ttl * 1_000 : Number.MAX_SAFE_INTEGER, + }); + + return data; + } +} diff --git a/core/lib/fetch-cache/adapters/upstash-redis.ts b/core/lib/fetch-cache/adapters/upstash-redis.ts new file mode 100644 index 0000000000..e7ffe396f6 --- /dev/null +++ b/core/lib/fetch-cache/adapters/upstash-redis.ts @@ -0,0 +1,57 @@ +import { Redis } from '@upstash/redis'; + +import { FetchCacheAdapter, FetchCacheOptions } from '../types'; + +export class UpstashRedisFetchCacheAdapter implements FetchCacheAdapter { + private redis = Redis.fromEnv(); + + async get(cacheKey: string): Promise { + try { + const result = await this.redis.get(cacheKey); + return result; + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Upstash Redis get failed for key ${cacheKey}:`, error); + return null; + } + } + + async mget(...cacheKeys: string[]): Promise> { + try { + const result = await this.redis.mget(cacheKeys); + + // Redis mget returns an array, but we need to handle the case where some values might be null + return Array.isArray(result) ? result : cacheKeys.map(() => null); + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Upstash Redis mget failed for keys [${cacheKeys.join(', ')}]:`, error); + return cacheKeys.map(() => null); + } + } + + async set(cacheKey: string, data: T, options: FetchCacheOptions = {}): Promise { + try { + // Build Redis options - support TTL but ignore tags (not supported by Redis) + const { ttl, tags, ...redisOpts } = options; + const redisOptions: Record = { ...redisOpts }; + + // Add TTL if provided (Redis EX parameter for seconds) + if (ttl) { + redisOptions.ex = ttl; + } + + const response = await this.redis.set( + cacheKey, + data, + Object.keys(redisOptions).length > 0 ? redisOptions : undefined, + ); + + // Redis SET returns 'OK' on success, null on failure + return response === 'OK' ? data : null; + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Upstash Redis set failed for key ${cacheKey}:`, error); + return null; + } + } +} diff --git a/core/lib/fetch-cache/adapters/vercel-runtime-cache.ts b/core/lib/fetch-cache/adapters/vercel-runtime-cache.ts new file mode 100644 index 0000000000..58f914c62d --- /dev/null +++ b/core/lib/fetch-cache/adapters/vercel-runtime-cache.ts @@ -0,0 +1,70 @@ +import { FetchCacheAdapter, FetchCacheOptions } from '../types'; + +export class VercelRuntimeCacheAdapter implements FetchCacheAdapter { + async get(cacheKey: string): Promise { + try { + const { getCache } = await import('@vercel/functions'); + const cache = getCache(); + const result = await cache.get(cacheKey); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return result as T | null; + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Vercel runtime cache get failed for key ${cacheKey}:`, error); + return null; + } + } + + async mget(...cacheKeys: string[]): Promise> { + const { getCache } = await import('@vercel/functions'); + const cache = getCache(); + + const values = await Promise.all( + cacheKeys.map(async (key) => { + try { + const result = await cache.get(key); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return result as T | null; + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Vercel runtime cache get failed for key ${key}:`, error); + return null; + } + }), + ); + + return values; + } + + async set(cacheKey: string, data: T, options: FetchCacheOptions = {}): Promise { + try { + const { getCache } = await import('@vercel/functions'); + const cache = getCache(); + + // Build runtime cache options + const runtimeCacheOptions: Record = {}; + + if (options.ttl) { + runtimeCacheOptions.ttl = options.ttl; + } + + if (options.tags && Array.isArray(options.tags)) { + runtimeCacheOptions.tags = options.tags; + } + + // Call cache.set with options if provided, otherwise call without options + if (Object.keys(runtimeCacheOptions).length > 0) { + await cache.set(cacheKey, data, runtimeCacheOptions); + } else { + await cache.set(cacheKey, data); + } + + return data; + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Vercel runtime cache set failed for key ${cacheKey}:`, error); + return null; + } + } +} diff --git a/core/lib/fetch-cache/index.ts b/core/lib/fetch-cache/index.ts new file mode 100644 index 0000000000..ea71ae21d0 --- /dev/null +++ b/core/lib/fetch-cache/index.ts @@ -0,0 +1,362 @@ +import { FetchCacheLogger, timer } from './lib/cache-logger'; +import { MemoryFetchCacheAdapter } from './adapters/memory'; +import { FetchCacheAdapter, FetchCacheOptions } from './types'; + +interface FetchCacheConfig { + logger?: boolean; + loggerPrefix?: string; +} + +class TwoLayerFetchCache { + private memoryCache = new MemoryFetchCacheAdapter(); + private backendAdapter?: FetchCacheAdapter; + private logger: FetchCacheLogger; + private backendName: string; + + constructor( + private createBackendAdapter: () => Promise, + private config: FetchCacheConfig = {}, + backendName = 'Backend', + ) { + this.backendName = backendName; + this.logger = new FetchCacheLogger({ + enabled: config.logger ?? false, + prefix: config.loggerPrefix ?? '[Fetch Cache]', + }); + } + + async get(cacheKey: string): Promise { + const [value] = await this.mget(cacheKey); + return value ?? null; + } + + async mget(...cacheKeys: string[]): Promise> { + const startTime = timer(); + + // Step 1: Check memory cache + const memoryStartTime = timer(); + const memoryValues = await this.memoryCache.mget(...cacheKeys); + const memoryTime = timer() - memoryStartTime; + + // Analyze memory hits + const memoryHits = memoryValues.filter((value) => value !== null).length; + + // If all values found in memory, return early + if (memoryHits === cacheKeys.length) { + const totalTime = timer() - startTime; + + this.logger.logOperation({ + operation: 'BATCH_FETCH', + cacheKeys, + memoryHits, + backendHits: 0, + totalMisses: 0, + memoryTime, + totalTime, + backend: this.backendName, + }); + + return memoryValues; + } + + // Step 2: Get missing keys from backend + const backendStartTime = timer(); + const backend = await this.getBackendAdapter(); + + // Identify keys that need to be fetched from backend + const keysToFetch = cacheKeys.filter((_, index) => memoryValues[index] === null); + const backendValues = await backend.mget(...keysToFetch); + const backendTime = timer() - backendStartTime; + + // Step 3: Merge results and update memory cache + const finalValues: Array = []; + let backendIndex = 0; + + const backendValuesToCache: Array<{ key: string; value: T }> = []; + + for (let i = 0; i < cacheKeys.length; i++) { + const memoryValue = memoryValues[i]; + const currentKey = cacheKeys[i]; + + if (memoryValue !== null && memoryValue !== undefined) { + // Use value from memory + finalValues[i] = memoryValue; + } else { + // Use value from backend + const backendValue = backendValues[backendIndex]; + finalValues[i] = backendValue ?? null; + + // Queue for memory cache if not null and key exists + if (backendValue !== null && backendValue !== undefined && currentKey) { + backendValuesToCache.push({ key: currentKey, value: backendValue }); + } + + backendIndex++; + } + } + + // Update memory cache with backend values (don't await - fire and forget) + if (backendValuesToCache.length > 0) { + Promise.all( + backendValuesToCache.map(({ key, value }) => this.memoryCache.set(key, value)), + ).catch((error) => { + // eslint-disable-next-line no-console + console.warn('Failed to update memory cache:', error); + }); + } + + // Step 4: Calculate final statistics and log + const backendHits = backendValues.filter((value) => value !== null).length; + const totalMisses = finalValues.filter((value) => value === null).length; + const totalTime = timer() - startTime; + + this.logger.logOperation({ + operation: 'BATCH_FETCH', + cacheKeys, + memoryHits, + backendHits, + totalMisses, + memoryTime, + backendTime, + totalTime, + backend: this.backendName, + }); + + return finalValues; + } + + async set(cacheKey: string, data: T, options: FetchCacheOptions = {}): Promise { + const startTime = timer(); + + // Step 1: Set in memory cache + const memoryStartTime = timer(); + await this.memoryCache.set(cacheKey, data, options); + const memoryTime = timer() - memoryStartTime; + + // Step 2: Set in backend + const backendStartTime = timer(); + const backend = await this.getBackendAdapter(); + const result = await backend.set(cacheKey, data, options); + const backendTime = timer() - backendStartTime; + + const totalTime = timer() - startTime; + + this.logger.logOperation({ + operation: 'CACHE_SET', + cacheKeys: [cacheKey], + memoryTime, + backendTime, + totalTime, + options, + backend: this.backendName, + }); + + return result; + } + + private async getBackendAdapter(): Promise { + if (!this.backendAdapter) { + this.backendAdapter = await this.createBackendAdapter(); + } + return this.backendAdapter; + } +} + +async function createFetchCacheAdapter(): Promise<{ adapter: FetchCacheAdapter; name: string }> { + // Feature detection for Cloudflare Workers + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (typeof globalThis !== 'undefined' && (globalThis as any).caches?.default) { + const { CloudflareNativeFetchCacheAdapter } = await import('./adapters/cloudflare-native'); + return { adapter: new CloudflareNativeFetchCacheAdapter(), name: 'Cloudflare Native' }; + } + + // Vercel Edge Runtime + if (process.env.VERCEL === '1') { + const { VercelRuntimeCacheAdapter } = await import('./adapters/vercel-runtime-cache'); + return { adapter: new VercelRuntimeCacheAdapter(), name: 'Vercel Runtime Cache' }; + } + + // Upstash Redis + if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) { + const { UpstashRedisFetchCacheAdapter } = await import('./adapters/upstash-redis'); + return { adapter: new UpstashRedisFetchCacheAdapter(), name: 'Upstash Redis' }; + } + + // Fallback to memory-only + return { adapter: new MemoryFetchCacheAdapter(), name: 'Memory Only' }; +} + +// Create the global fetch cache instance +const createFetchCacheInstance = async () => { + const { adapter, name } = await createFetchCacheAdapter(); + + return new TwoLayerFetchCache( + async () => adapter, + { + logger: + (process.env.NODE_ENV !== 'production' && process.env.FETCH_CACHE_LOGGER !== 'false') || + process.env.FETCH_CACHE_LOGGER === 'true', + loggerPrefix: '[BigCommerce Fetch Cache]', + }, + name, + ); +}; + +const fetchCacheInstance = await createFetchCacheInstance(); + +/** + * Fetch data with TTL caching using a 2-layer cache strategy (memory + backend). + * + * This function provides a drop-in replacement for data fetching in Next.js middleware + * where the normal fetch cache is not available. + * + * @param fetcher - Function that fetches the data (e.g., API call) + * @param cacheKey - Unique key for caching this data + * @param options - Cache options including TTL and tags + * + * @example + * ```typescript + * const userData = await fetchWithTTLCache( + * async () => { + * const response = await fetch('/api/user'); + * return response.json(); + * }, + * 'user:123', + * { ttl: 300 } // 5 minutes + * ); + * ``` + */ +export async function fetchWithTTLCache( + fetcher: () => Promise, + cacheKey: string, + options: FetchCacheOptions = {}, +): Promise { + const startTime = timer(); + + // Try to get from cache first + const cachedData = await fetchCacheInstance.get(cacheKey); + + if (cachedData !== null) { + const totalTime = timer() - startTime; + + fetchCacheInstance['logger'].logOperation({ + operation: 'FETCH', + cacheKeys: [cacheKey], + memoryHits: 1, // We don't know the source, but we got a hit + backendHits: 0, + totalMisses: 0, + totalTime, + backend: fetchCacheInstance['backendName'], + }); + + return cachedData; + } + + // Cache miss - fetch fresh data + const fetchStartTime = timer(); + const freshData = await fetcher(); + const fetchTime = timer() - fetchStartTime; + + // Store in cache (fire and forget) + fetchCacheInstance.set(cacheKey, freshData, options).catch((error) => { + // eslint-disable-next-line no-console + console.warn('Failed to cache data:', error); + }); + + const totalTime = timer() - startTime; + + fetchCacheInstance['logger'].logOperation({ + operation: 'FETCH', + cacheKeys: [cacheKey], + memoryHits: 0, + backendHits: 0, + totalMisses: 1, + backendTime: fetchTime, // This is the actual fetch time + totalTime, + backend: fetchCacheInstance['backendName'], + }); + + return freshData; +} + +/** + * Batch fetch multiple pieces of data with TTL caching. + * + * This is useful when you need to fetch multiple related pieces of data + * and want to optimize cache hits. + * + * @param requests - Array of fetch requests with cache keys + * @param options - Default cache options (can be overridden per request) + * + * @example + * ```typescript + * const results = await batchFetchWithTTLCache([ + * { + * fetcher: () => getRoute(pathname, channelId), + * cacheKey: kvKey(pathname, channelId), + * options: { ttl: 86400 } + * }, + * { + * fetcher: () => getStoreStatus(channelId), + * cacheKey: kvKey(STORE_STATUS_KEY, channelId), + * options: { ttl: 3600 } + * } + * ]); + * ``` + */ +export async function batchFetchWithTTLCache( + requests: Array<{ + fetcher: () => Promise; + cacheKey: string; + options?: FetchCacheOptions; + }>, + defaultOptions: FetchCacheOptions = {}, +): Promise> { + const cacheKeys = requests.map((req) => req.cacheKey); + + // Try to get all from cache first + const cachedValues = await fetchCacheInstance.mget(...cacheKeys); + + // Identify which ones need to be fetched + const toFetch: Array<{ index: number; request: (typeof requests)[0] }> = []; + + cachedValues.forEach((value, index) => { + if (value === null) { + const request = requests[index]; + if (request) { + toFetch.push({ index, request }); + } + } + }); + + // Fetch missing data + if (toFetch.length > 0) { + const fetchPromises = toFetch.map(async ({ index, request }) => { + const freshData = await request.fetcher(); + const options = { ...defaultOptions, ...request.options }; + + // Store in cache (fire and forget) + fetchCacheInstance.set(request.cacheKey, freshData, options).catch((error) => { + // eslint-disable-next-line no-console + console.warn('Failed to cache batch data:', error); + }); + + return { index, data: freshData }; + }); + + const fetchResults = await Promise.all(fetchPromises); + + // Merge cached and fresh data + const finalResults = [...cachedValues]; + fetchResults.forEach(({ index, data }) => { + finalResults[index] = data; + }); + + return finalResults; + } + + return cachedValues; +} + +// Expose the cache instance for direct access if needed +export { fetchCacheInstance as fetchCache }; diff --git a/core/lib/fetch-cache/keys.ts b/core/lib/fetch-cache/keys.ts new file mode 100644 index 0000000000..3121e466d5 --- /dev/null +++ b/core/lib/fetch-cache/keys.ts @@ -0,0 +1,39 @@ +/** + * Generate a cache key for the fetch cache system. + * This creates a consistent, scoped key for caching fetched data. + * + * @param key - The base key (e.g., pathname, API endpoint identifier) + * @param scope - Optional scope (e.g., channelId, userId) to namespace the key + * @returns A formatted cache key + */ +export function cacheKey(key: string, scope?: string): string { + if (scope) { + return `${scope}:${key}`; + } + return key; +} + +// Common cache keys used throughout the application +export const STORE_STATUS_KEY = 'store-status'; +export const ROUTE_KEY_PREFIX = 'route'; + +/** + * Generate a route cache key. + * + * @param pathname - The route pathname + * @param channelId - The channel ID for scoping + * @returns A formatted route cache key + */ +export function routeCacheKey(pathname: string, channelId: string): string { + return cacheKey(`${ROUTE_KEY_PREFIX}:${pathname}`, channelId); +} + +/** + * Generate a store status cache key. + * + * @param channelId - The channel ID for scoping + * @returns A formatted store status cache key + */ +export function storeStatusCacheKey(channelId: string): string { + return cacheKey(STORE_STATUS_KEY, channelId); +} diff --git a/core/lib/fetch-cache/lib/cache-logger.ts b/core/lib/fetch-cache/lib/cache-logger.ts new file mode 100644 index 0000000000..5d20c136d7 --- /dev/null +++ b/core/lib/fetch-cache/lib/cache-logger.ts @@ -0,0 +1,136 @@ +interface FetchCacheOperation { + operation: 'FETCH' | 'BATCH_FETCH' | 'CACHE_SET'; + cacheKeys: string[]; + memoryHits?: number; + backendHits?: number; + totalMisses?: number; + memoryTime?: number; + backendTime?: number; + totalTime: number; + options?: Record; + backend?: string; +} + +interface FetchCacheLoggerConfig { + enabled: boolean; + prefix?: string; +} + +export class FetchCacheLogger { + private config: FetchCacheLoggerConfig; + + constructor(config: FetchCacheLoggerConfig) { + this.config = config; + } + + logOperation(operation: FetchCacheOperation): void { + if (!this.config.enabled) return; + + const prefix = this.config.prefix || '[Fetch Cache]'; + const { operation: op, cacheKeys, totalTime, backend } = operation; + + // Build the main message + const keyStr = cacheKeys.length === 1 ? cacheKeys[0] : `[${cacheKeys.join(', ')}]`; + let message = `${prefix} ${op} ${keyStr}`; + + // Add backend info if available + if (backend) { + message += ` (${backend})`; + } + + // Add hit/miss analysis for fetch operations + if (op === 'FETCH' || op === 'BATCH_FETCH') { + const analysis = this.buildHitMissAnalysis(operation); + if (analysis) { + message += ` - ${analysis}`; + } + } + + // Add timing breakdown + const timing = this.buildTimingBreakdown(operation); + if (timing) { + message += ` - ${timing}`; + } + + // Add options if present (for CACHE_SET operations) + if (operation.options && Object.keys(operation.options).length > 0) { + const opts = this.formatOptions(operation.options); + message += ` - ${opts}`; + } + + // eslint-disable-next-line no-console + console.log(message); + } + + private buildHitMissAnalysis(operation: FetchCacheOperation): string { + const { cacheKeys, memoryHits = 0, backendHits = 0, totalMisses = 0 } = operation; + const total = cacheKeys.length; + + if (memoryHits === total) { + return '✓ All from memory cache'; + } + + if (memoryHits + backendHits === total) { + if (memoryHits > 0) { + return `✓ Memory: ${memoryHits}, Backend: ${backendHits}`; + } + return `✓ All from backend cache`; + } + + // Some misses - need to fetch fresh data + const parts = []; + if (memoryHits > 0) parts.push(`Memory: ${memoryHits}`); + if (backendHits > 0) parts.push(`Backend: ${backendHits}`); + if (totalMisses > 0) parts.push(`✗ Fetch required: ${totalMisses}`); + + return parts.join(', '); + } + + private buildTimingBreakdown(operation: FetchCacheOperation): string { + const { memoryTime, backendTime, totalTime } = operation; + const parts = []; + + if (memoryTime !== undefined) { + parts.push(`Memory: ${memoryTime.toFixed(2)}ms`); + } + + if (backendTime !== undefined) { + parts.push(`Backend: ${backendTime.toFixed(2)}ms`); + } + + parts.push(`Total: ${totalTime.toFixed(2)}ms`); + + return parts.join(', '); + } + + private formatOptions(options: Record): string { + const parts = []; + + if (options.ttl) { + parts.push(`TTL: ${options.ttl}s`); + } + + if (Array.isArray(options.tags) && options.tags.length > 0) { + parts.push(`Tags: [${options.tags.join(', ')}]`); + } + + // Add other relevant options + Object.entries(options).forEach(([key, value]) => { + if (key !== 'ttl' && key !== 'tags' && value !== undefined) { + parts.push(`${key}: ${String(value)}`); + } + }); + + return parts.length > 0 ? `Options: ${parts.join(', ')}` : ''; + } +} + +// Performance timing utility with feature detection +export const getPerformanceTimer = (): (() => number) => { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return () => performance.now(); + } + return () => Date.now(); +}; + +export const timer = getPerformanceTimer(); diff --git a/core/lib/fetch-cache/types.ts b/core/lib/fetch-cache/types.ts new file mode 100644 index 0000000000..dec14c5c65 --- /dev/null +++ b/core/lib/fetch-cache/types.ts @@ -0,0 +1,20 @@ +export interface FetchCacheOptions { + /** Time to live in seconds */ + ttl?: number; + /** Cache tags for invalidation (when supported by backend) */ + tags?: string[]; + /** Additional backend-specific options */ + [key: string]: unknown; +} + +export interface FetchCacheAdapter { + get(cacheKey: string): Promise; + set(cacheKey: string, data: T, options?: FetchCacheOptions): Promise; + mget(...cacheKeys: string[]): Promise>; +} + +export interface FetchCacheResult { + data: T; + fromCache: boolean; + cacheSource?: 'memory' | 'backend'; +} diff --git a/core/lib/kv/adapters/bc.ts b/core/lib/kv/adapters/bc.ts deleted file mode 100644 index ead6ff7f1f..0000000000 --- a/core/lib/kv/adapters/bc.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* eslint-disable max-classes-per-file */ -/* eslint-disable @typescript-eslint/consistent-type-assertions */ -import { KvAdapter } from '../types'; - -class Kv { - private endpoint: string; - private apiKey: string; - - constructor() { - if (!process.env.BC_KV_REST_API_URL) { - throw new Error('BC_KV_REST_API_URL is not set'); - } - - if (!process.env.BC_KV_REST_API_TOKEN) { - throw new Error('BC_KV_REST_API_TOKEN is not set'); - } - - this.endpoint = process.env.BC_KV_REST_API_URL; - this.apiKey = process.env.BC_KV_REST_API_TOKEN; - } - - async get(key: string): Promise { - const [value] = await this.mget<[T]>([key]); - - return value; - } - - async mget(keys: string[]): Promise<{ [K in keyof T]: T[K] | null }> { - const normalizedKeys = Array.isArray(keys) ? keys : [keys]; - - const url = new URL(this.endpoint); - - normalizedKeys.forEach((key) => url.searchParams.append('key', key)); - - const response = await fetch(url, { - headers: { 'x-api-key': this.apiKey }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch keys: ${response.statusText}`); - } - - const { data } = (await response.json()) as { data: Array<{ key: string; value: unknown }> }; - - return data.map(({ value }) => value) as { [K in keyof T]: T[K] | null }; - } - - async set(key: string, value: unknown, ttlMs?: number): Promise { - return this.mset([{ key, value, ttlMs }]); - } - - async mset(data: Array<{ key: string; value: unknown; ttlMs?: number }>): Promise { - const response = await fetch(this.endpoint, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error(`Failed to put keys: ${response.statusText}`); - } - } - - async delete(key: string): Promise { - return this.mdelete([key]); - } - - async mdelete(keys: string[]): Promise { - const url = new URL(this.endpoint); - - keys.forEach((key) => url.searchParams.append('key', key)); - - const response = await fetch(url, { - method: 'DELETE', - headers: { 'x-api-key': this.apiKey }, - }); - - if (!response.ok) { - throw new Error(`Failed to delete keys: ${response.statusText}`); - } - } -} - -export class BcKvAdapter implements KvAdapter { - private kv = new Kv(); - - async mget(...keys: string[]) { - const values = await this.kv.mget(keys); - - return values; - } - - async set(key: string, value: Data, opts?: { ttlMs?: number }) { - await this.kv.set(key, value, opts?.ttlMs); - - return value; - } -} diff --git a/core/lib/kv/adapters/memory.ts b/core/lib/kv/adapters/memory.ts deleted file mode 100644 index 50fb34e594..0000000000 --- a/core/lib/kv/adapters/memory.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable @typescript-eslint/require-await */ -import { LRUCache } from 'lru-cache'; - -import { KvAdapter } from '../types'; - -interface CacheEntry { - value: unknown; - expiresAt: number; -} - -export class MemoryKvAdapter implements KvAdapter { - private kv = new LRUCache({ - max: 500, - }); - - async mget(...keys: string[]) { - const entries = keys.map((key) => this.kv.get(key)?.value); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return entries as Data[]; - } - - async set(key: string, value: Data, options: { ex?: number } = {}) { - this.kv.set(key, { - value, - expiresAt: options.ex ? Date.now() + options.ex * 1_000 : Number.MAX_SAFE_INTEGER, - }); - - return value; - } - - private async get(key: string) { - const entry = this.kv.get(key); - - if (!entry) { - return null; - } - - if (entry.expiresAt < Date.now()) { - return null; - } - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return entry.value as Data; - } -} diff --git a/core/lib/kv/adapters/upstash.ts b/core/lib/kv/adapters/upstash.ts deleted file mode 100644 index 2922f14328..0000000000 --- a/core/lib/kv/adapters/upstash.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Redis } from '@upstash/redis'; - -import { KvAdapter, SetCommandOptions } from '../types'; - -import { MemoryKvAdapter } from './memory'; - -const memoryKv = new MemoryKvAdapter(); - -export class UpstashKvAdapter implements KvAdapter { - private upstashKv = Redis.fromEnv(); - private memoryKv = memoryKv; - - async mget(...keys: string[]) { - const memoryValues = await this.memoryKv.mget(...keys); - - return memoryValues.length ? memoryValues : this.upstashKv.mget(keys); - } - - async set(key: string, value: Data, opts?: SetCommandOptions) { - await this.memoryKv.set(key, value, opts); - - const response = await this.upstashKv.set(key, value, opts); - - if (response === 'OK') { - return null; - } - - return response; - } -} diff --git a/core/lib/kv/adapters/vercel.ts b/core/lib/kv/adapters/vercel.ts deleted file mode 100644 index eb26ea6e3e..0000000000 --- a/core/lib/kv/adapters/vercel.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { kv } from '@vercel/kv'; - -import { KvAdapter, SetCommandOptions } from '../types'; - -import { MemoryKvAdapter } from './memory'; - -const memoryKv = new MemoryKvAdapter(); - -export class VercelKvAdapter implements KvAdapter { - private vercelKv = kv; - private memoryKv = memoryKv; - - async mget(...keys: string[]) { - const memoryValues = await this.memoryKv.mget(...keys); - - return memoryValues.length ? memoryValues : this.vercelKv.mget(keys); - } - - async set(key: string, value: Data, opts?: SetCommandOptions) { - await this.memoryKv.set(key, value, opts); - - const response = await this.vercelKv.set(key, value, opts); - - if (response === 'OK') { - return null; - } - - return response; - } -} diff --git a/core/lib/kv/index.ts b/core/lib/kv/index.ts deleted file mode 100644 index 42472c2eaa..0000000000 --- a/core/lib/kv/index.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { MemoryKvAdapter } from './adapters/memory'; -import { KvAdapter, SetCommandOptions } from './types'; - -interface Config { - logger?: boolean; -} - -const memoryKv = new MemoryKvAdapter(); - -class KV implements KvAdapter { - private kv?: Adapter; - private memoryKv = memoryKv; - - constructor( - private createAdapter: () => Promise, - private config: Config = {}, - ) {} - - async get(key: string) { - const [value] = await this.mget(key); - - return value ?? null; - } - - async mget(...keys: string[]) { - const kv = await this.getKv(); - - const memoryValues = (await this.memoryKv.mget(...keys)).filter(Boolean); - - if (memoryValues.length === keys.length) { - this.logger( - `MGET - Keys: ${keys.toString()} - Value: ${JSON.stringify(memoryValues, null, 2)}`, - ); - - return memoryValues; - } - - const values = await kv.mget(...keys); - - this.logger(`MGET - Keys: ${keys.toString()} - Value: ${JSON.stringify(values, null, 2)}`); - - // Store the values in memory kv - await Promise.all( - values.map(async (value, index) => { - const key = keys[index]; - - if (!key) { - return; - } - - await this.memoryKv.set(key, value); - }), - ); - - return values; - } - - async set(key: string, value: Data, opts?: SetCommandOptions) { - const kv = await this.getKv(); - - this.logger(`SET - Key: ${key} - Value: ${JSON.stringify(value, null, 2)}`); - - await Promise.all([this.memoryKv.set(key, value, opts), kv.set(key, value, opts)]); - - return value; - } - - private async getKv() { - if (!this.kv) { - this.kv = await this.createAdapter(); - } - - return this.kv; - } - - private logger(message: string) { - if (this.config.logger) { - // eslint-disable-next-line no-console - console.log(`[BigCommerce] KV ${message}`); - } - } -} - -async function createKVAdapter() { - if (process.env.BC_KV_REST_API_URL && process.env.BC_KV_REST_API_TOKEN) { - const { BcKvAdapter } = await import('./adapters/bc'); - - return new BcKvAdapter(); - } - - if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { - const { VercelKvAdapter } = await import('./adapters/vercel'); - - return new VercelKvAdapter(); - } - - if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) { - const { UpstashKvAdapter } = await import('./adapters/upstash'); - - return new UpstashKvAdapter(); - } - - return new MemoryKvAdapter(); -} - -const adapterInstance = new KV(createKVAdapter, { - logger: - (process.env.NODE_ENV !== 'production' && process.env.KV_LOGGER !== 'false') || - process.env.KV_LOGGER === 'true', -}); - -export { adapterInstance as kv }; diff --git a/core/lib/kv/keys.ts b/core/lib/kv/keys.ts deleted file mode 100644 index e339e82b01..0000000000 --- a/core/lib/kv/keys.ts +++ /dev/null @@ -1,10 +0,0 @@ -const VERSION = 'v3'; - -export const STORE_STATUS_KEY = 'storeStatus'; - -export const kvKey = (key: string, channelId?: string) => { - const namespace = process.env.KV_NAMESPACE ?? process.env.BIGCOMMERCE_STORE_HASH ?? 'store'; - const id = channelId ?? process.env.BIGCOMMERCE_CHANNEL_ID ?? '1'; - - return `${namespace}_${id}_${VERSION}_${key}`; -}; diff --git a/core/lib/kv/types.ts b/core/lib/kv/types.ts deleted file mode 100644 index 6aaf6a32b9..0000000000 --- a/core/lib/kv/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type SetCommandOptions = Record; - -export interface KvAdapter { - mget(...keys: string[]): Promise>; - set(key: string, value: Data, opts?: SetCommandOptions): Promise; -} diff --git a/core/middlewares/with-routes.ts b/core/middlewares/with-routes.ts index 162c7f52d5..8febad8827 100644 --- a/core/middlewares/with-routes.ts +++ b/core/middlewares/with-routes.ts @@ -6,9 +6,8 @@ import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; import { getVisitIdCookie, getVisitorIdCookie } from '~/lib/analytics/bigcommerce'; import { sendProductViewedEvent } from '~/lib/analytics/bigcommerce/data-events'; -import { kvKey, STORE_STATUS_KEY } from '~/lib/kv/keys'; - -import { kv } from '../lib/kv'; +import { fetchWithTTLCache, batchFetchWithTTLCache } from '~/lib/fetch-cache'; +import { routeCacheKey, storeStatusCacheKey } from '~/lib/fetch-cache/keys'; import { type MiddlewareFactory } from './compose-middlewares'; @@ -122,25 +121,12 @@ const getStoreStatus = async (channelId?: string) => { type Route = Awaited>; type StorefrontStatusType = ReturnType>; -interface RouteCache { - route: Route; - expiryTime: number; -} - -interface StorefrontStatusCache { - status: StorefrontStatusType; - expiryTime: number; -} - -const StorefrontStatusCacheSchema = z.object({ - status: z.union([ - z.literal('HIBERNATION'), - z.literal('LAUNCHED'), - z.literal('MAINTENANCE'), - z.literal('PRE_LAUNCH'), - ]), - expiryTime: z.number(), -}); +const StorefrontStatusSchema = z.union([ + z.literal('HIBERNATION'), + z.literal('LAUNCHED'), + z.literal('MAINTENANCE'), + z.literal('PRE_LAUNCH'), +]); const RedirectSchema = z.object({ to: z.union([ @@ -171,45 +157,11 @@ const RouteSchema = z.object({ node: z.nullable(NodeSchema), }); -const RouteCacheSchema = z.object({ - route: z.nullable(RouteSchema), - expiryTime: z.number(), -}); - -const updateRouteCache = async ( - pathname: string, - channelId: string, - event: NextFetchEvent, -): Promise => { - const routeCache: RouteCache = { - route: await getRoute(pathname, channelId), - expiryTime: Date.now() + 1000 * 60 * 30, // 30 minutes - }; - - event.waitUntil(kv.set(kvKey(pathname, channelId), routeCache)); - - return routeCache; -}; +// Cache TTL configuration from environment variables +const ROUTE_CACHE_TTL = parseInt(process.env.ROUTE_CACHE_TTL || '86400', 10); // Default: 24 hours +const STORE_STATUS_CACHE_TTL = parseInt(process.env.STORE_STATUS_CACHE_TTL || '3600', 10); // Default: 1 hour -const updateStatusCache = async ( - channelId: string, - event: NextFetchEvent, -): Promise => { - const status = await getStoreStatus(channelId); - - if (status === undefined) { - throw new Error('Failed to fetch new storefront status'); - } - - const statusCache: StorefrontStatusCache = { - status, - expiryTime: Date.now() + 1000 * 60 * 5, // 5 minutes - }; - - event.waitUntil(kv.set(kvKey(STORE_STATUS_KEY, channelId), statusCache)); - - return statusCache; -}; +// Functions removed - caching is now handled automatically by fetchWithTTLCache const clearLocaleFromPath = (path: string, locale: string) => { if (path === `/${locale}` || path === `/${locale}/`) { @@ -231,31 +183,33 @@ const getRouteInfo = async (request: NextRequest, event: NextFetchEvent) => { // For route resolution parity, we need to also include query params, otherwise certain redirects will not work. const pathname = clearLocaleFromPath(request.nextUrl.pathname + request.nextUrl.search, locale); - let [routeCache, statusCache] = await kv.mget( - kvKey(pathname, channelId), - kvKey(STORE_STATUS_KEY, channelId), - ); - - // If caches are old, update them in the background and return the old data (SWR-like behavior) - // If cache is missing, update it and return the new data, but write to KV in the background - if (statusCache && statusCache.expiryTime < Date.now()) { - event.waitUntil(updateStatusCache(channelId, event)); - } else if (!statusCache) { - statusCache = await updateStatusCache(channelId, event); - } - - if (routeCache && routeCache.expiryTime < Date.now()) { - event.waitUntil(updateRouteCache(pathname, channelId, event)); - } else if (!routeCache) { - routeCache = await updateRouteCache(pathname, channelId, event); - } + // Use batch fetch with TTL caching - much cleaner than manual cache management + const [route, status] = await batchFetchWithTTLCache([ + { + fetcher: () => getRoute(pathname, channelId), + cacheKey: routeCacheKey(pathname, channelId), + options: { ttl: ROUTE_CACHE_TTL }, + }, + { + fetcher: async () => { + const fetchedStatus = await getStoreStatus(channelId); + if (fetchedStatus === undefined) { + throw new Error('Failed to fetch storefront status'); + } + return fetchedStatus; + }, + cacheKey: storeStatusCacheKey(channelId), + options: { ttl: STORE_STATUS_CACHE_TTL }, + }, + ]); - const parsedRoute = RouteCacheSchema.safeParse(routeCache); - const parsedStatus = StorefrontStatusCacheSchema.safeParse(statusCache); + // Simple validation of the fetched/cached data + const parsedRoute = RouteSchema.nullable().safeParse(route); + const parsedStatus = StorefrontStatusSchema.safeParse(status); return { - route: parsedRoute.success ? parsedRoute.data.route : undefined, - status: parsedStatus.success ? parsedStatus.data.status : undefined, + route: parsedRoute.success ? parsedRoute.data : undefined, + status: parsedStatus.success ? parsedStatus.data : undefined, }; } catch (error) { // eslint-disable-next-line no-console diff --git a/core/package.json b/core/package.json index f03c429238..eacbf23762 100644 --- a/core/package.json +++ b/core/package.json @@ -34,7 +34,7 @@ "@t3-oss/env-core": "^0.13.6", "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.5.0", - "@vercel/kv": "^3.0.0", + "@vercel/functions": "^2.2.0", "@vercel/speed-insights": "^1.2.0", "clsx": "^2.1.1", "content-security-policy-builder": "^2.3.0", @@ -50,7 +50,7 @@ "lodash.debounce": "^4.0.8", "lru-cache": "^11.1.0", "lucide-react": "^0.474.0", - "next": "15.4.0-canary.0", + "next": "15.4.0-canary.85", "next-auth": "5.0.0-beta.25", "next-intl": "^4.1.0", "nuqs": "^2.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6082aaa915..8435545e4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,13 +91,13 @@ importers: version: 1.35.0 '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) - '@vercel/kv': - specifier: ^3.0.0 - version: 3.0.0 + version: 1.5.0(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) + '@vercel/functions': + specifier: ^2.2.0 + version: 2.2.0 '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.2.0(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) + version: 1.2.0(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -141,17 +141,17 @@ importers: specifier: ^0.474.0 version: 0.474.0(react@19.1.0) next: - specifier: 15.4.0-canary.0 - version: 15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 15.4.0-canary.85 + version: 15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-auth: specifier: 5.0.0-beta.25 - version: 5.0.0-beta.25(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@6.9.16)(react@19.1.0) + version: 5.0.0-beta.25(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@6.9.16)(react@19.1.0) next-intl: specifier: ^4.1.0 - version: 4.1.0(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + version: 4.1.0(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 2.4.3(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) p-lazy: specifier: ^5.0.0 version: 5.0.0 @@ -197,7 +197,7 @@ importers: version: 1.12.16(graphql@16.11.0)(typescript@5.8.3) '@bigcommerce/eslint-config': specifier: ^2.11.0 - version: 2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30))(typescript@5.8.3) + version: 2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0)(typescript@5.8.3) '@bigcommerce/eslint-config-catalyst': specifier: workspace:^ version: link:../packages/eslint-config-catalyst @@ -294,7 +294,7 @@ importers: devDependencies: '@bigcommerce/eslint-config': specifier: ^2.11.0 - version: 2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30))(typescript@5.8.3) + version: 2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0)(typescript@5.8.3) '@bigcommerce/eslint-config-catalyst': specifier: workspace:^ version: link:../eslint-config-catalyst @@ -334,7 +334,7 @@ importers: devDependencies: '@bigcommerce/eslint-config': specifier: ^2.11.0 - version: 2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30))(typescript@5.8.3) + version: 2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0)(typescript@5.8.3) '@bigcommerce/eslint-config-catalyst': specifier: workspace:^ version: link:../eslint-config-catalyst @@ -431,7 +431,7 @@ importers: devDependencies: '@bigcommerce/eslint-config': specifier: ^2.11.0 - version: 2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30))(typescript@5.8.3) + version: 2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0)(typescript@5.8.3) '@bigcommerce/eslint-config-catalyst': specifier: workspace:^ version: link:../eslint-config-catalyst @@ -1194,9 +1194,6 @@ packages: '@emnapi/runtime@1.2.0': resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==} - '@emnapi/runtime@1.3.1': - resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} - '@emnapi/runtime@1.4.3': resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} @@ -1934,8 +1931,8 @@ packages: '@next/bundle-analyzer@15.2.3': resolution: {integrity: sha512-alZemRg2ciCTmT2WUbzy1M9H4luzmmlyZtdB4tHDA+qoD4WTNEwty+oxn3oIzDzIiMvOaODXUNdMrYsFnsAdEA==} - '@next/env@15.4.0-canary.0': - resolution: {integrity: sha512-1ClEsofgP+OnbxD4pxTPqEwLV49NviVE+FF1uQNSEiWc+sSWSXBAZEtp1OqETKtrrxuEORbM5u5lOvjiYBMj7w==} + '@next/env@15.4.0-canary.85': + resolution: {integrity: sha512-9WnWqH1DU6qAlEJUzQyT8L0TkpzKa6UQ/zYNB9fCUnvnkEkLo2OP8+CobpWzpRhOuyWkbPz5VmX15WZFfsDGkA==} '@next/eslint-plugin-next@15.2.3': resolution: {integrity: sha512-eNSOIMJtjs+dp4Ms1tB1PPPJUQHP3uZK+OQ7iFY9qXpGO6ojT6imCL+KcUOqE/GXGidWbBZJzYdgAdPHqeCEPA==} @@ -1943,50 +1940,50 @@ packages: '@next/eslint-plugin-next@15.3.3': resolution: {integrity: sha512-VKZJEiEdpKkfBmcokGjHu0vGDG+8CehGs90tBEy/IDoDDKGngeyIStt2MmE5FYNyU9BhgR7tybNWTAJY/30u+Q==} - '@next/swc-darwin-arm64@15.4.0-canary.0': - resolution: {integrity: sha512-xk6AuVAlnveKkBf7P9Nxa2qH80m3xtUEfwrRLKFPTt9BBGIHaSEp3fo7Mxi0NMiDZuCnv/lfhyqNwn7tLqUiyg==} + '@next/swc-darwin-arm64@15.4.0-canary.85': + resolution: {integrity: sha512-za0E9RDk5mubKtCC9VgZccKdUa+jigtnHfpcxWz3YEq5KI7hq20SuV/64ige/pKoT5+ROV6sqO+5RFhPqWzpwA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.4.0-canary.0': - resolution: {integrity: sha512-63QqXFbV2zixh7CZvwN3UBug6UJjzS8ileS4fgIc8yoSbcH0FL9DrAsrPQFYyU/Ah41mdT7sLCrsMzIn60kYYw==} + '@next/swc-darwin-x64@15.4.0-canary.85': + resolution: {integrity: sha512-YkRjaE0lSdppT4kCCyrEwMct0el/YvLRhceNhB5iHHacieEmVkU5IRHRpWL9b6VNM5tCuYj0lLohcmUTj2sDiw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.4.0-canary.0': - resolution: {integrity: sha512-9ttkvqy77ILjyvNrBe8juvwW/Opd2TIwiX1aK9L0GAxZh1KgW4l6OXOQvrRUznKXNPV5/MqUaXIk1uve7TghIw==} + '@next/swc-linux-arm64-gnu@15.4.0-canary.85': + resolution: {integrity: sha512-Mwoff9MQ78NL5zMXP/JOd/ArhLiC8zjN6dUp85Xx6lAiW/g0uzEjzAgrZga3K+07eSJjQ+7vGNdjVK3rl0odUA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.4.0-canary.0': - resolution: {integrity: sha512-oVNaoeDSJ9gBibvJmtRXdFjoyncHHlptgOUdldTtd2qtpV12GLVsMSyO/FN/hQi+dvUb8I2sNJAxGkt7o4oJwQ==} + '@next/swc-linux-arm64-musl@15.4.0-canary.85': + resolution: {integrity: sha512-TXAVI+oVHCHkHwBYYlQLabGxHHylnIHDB+HvXy9hcrY/BozUF9jEjZb1x9HLMORzWM60mfWsALl1j1putN7lRA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.4.0-canary.0': - resolution: {integrity: sha512-ww4nyrETjDl9MgEZ4PBYgFt/AEpB3je5Mkw1+Pyp+qOGei6w+z4qpZVooj5OvP41L+f3EJBxLk0T2wiVFSWyRQ==} + '@next/swc-linux-x64-gnu@15.4.0-canary.85': + resolution: {integrity: sha512-lG8E8HFpabOMyZG+BFqijEoB/Pd5MowaVogXaPcjHbnncIoZytvCHT2ZUc83/y3Aly1xpre+dVAShOsGzwm7XA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.4.0-canary.0': - resolution: {integrity: sha512-6KE4ecCaFcW0NzHyYrvFM6vCKfBLnmqGnNvSZqb1dt7FUbRkgbUHcxGgfX98AlSMlFX7W+bxzu7+3b8jTTnSjg==} + '@next/swc-linux-x64-musl@15.4.0-canary.85': + resolution: {integrity: sha512-4WALgXIS7oIw65OMoic4wEsiiVKYMh3nLcWAHuAks9kpoH1dIxF2B2ve58iXpqJIZ98xS7LfsMQoXqEK1aA6Eg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.4.0-canary.0': - resolution: {integrity: sha512-elw4XAbKXBzTYRElWWDqzqbEnsiRC0VD07Ap+ccnQmFgCxFKwHgqP3A98Mq8N5/SzyHs0GYKBjOJJaeWRVA4AQ==} + '@next/swc-win32-arm64-msvc@15.4.0-canary.85': + resolution: {integrity: sha512-DGgM++G920r3OHUU4X+HopxJZGGK+Gb+UWjDKepbxPtOTX64/XvAhoiA523a+eXm3ysi+kycuA0+jRlwrKEB/Q==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.4.0-canary.0': - resolution: {integrity: sha512-BE2FxKnOPdLHwX4u5ZrgqJUGGC2M8VsJIZhpYm7+CvYu1m5DZ3y3cgdOMv5p/DX2QAQ/++sJSB/XMNxbDt558A==} + '@next/swc-win32-x64-msvc@15.4.0-canary.85': + resolution: {integrity: sha512-nYHH5JH347B9ZdcKRIi8xhUnum9bjMRLL3f2ozKp942U1yucQn9N67HGhWDXHW0dy2Hzx2Em+PhlWTHe2vztSQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3275,9 +3272,14 @@ packages: vue-router: optional: true - '@vercel/kv@3.0.0': - resolution: {integrity: sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==} - engines: {node: '>=14.6'} + '@vercel/functions@2.2.0': + resolution: {integrity: sha512-x1Zrc2jOclTSB9+Ic/XNMDinO0SG4ZS5YeV2Xz1m/tuJOM7QtPVU3Epw2czBao0dukefmC8HCNpyUL8ZchJ/Tg==} + engines: {node: '>= 18'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true '@vercel/speed-insights@1.2.0': resolution: {integrity: sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw==} @@ -3708,10 +3710,6 @@ packages: peerDependencies: esbuild: '>=0.18' - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -6099,13 +6097,13 @@ packages: typescript: optional: true - next@15.4.0-canary.0: - resolution: {integrity: sha512-MOu/HJ+8ntZTiWkvM0AEqRXl8IF5ST58hU6PqakhNy3ZGyi0P/7ijqriYI6vn4GCapIhs1edFD8zC7LDdSkOwA==} + next@15.4.0-canary.85: + resolution: {integrity: sha512-xD+Es5CYPkb20//rQ9/vmWhx1TCohe8V2BhVzwkMv9UwAF8wMUK4GCyHyyXnvxqlNRfGG8KhDjS4IaTxq1ShEg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 + '@playwright/test': ^1.51.1 babel-plugin-react-compiler: '*' react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 @@ -7296,10 +7294,6 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - streamx@2.22.1: resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==} @@ -7506,6 +7500,9 @@ packages: third-party-web@0.26.6: resolution: {integrity: sha512-GsjP92xycMK8qLTcQCacgzvffYzEqe29wyz3zdKVXlfRD5Kz1NatCTOZEeDaSd6uCZXvGd2CNVtQ89RNIhJWvA==} + third-party-web@0.27.0: + resolution: {integrity: sha512-h0JYX+dO2Zr3abCQpS6/uFjujaOjA1DyDzGQ41+oFn9VW/ARiq9g5ln7qEP9+BTzDpOMyIfsfj4OvfgXAsMUSA==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -8508,39 +8505,6 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@bigcommerce/eslint-config@2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30))(typescript@5.8.3)': - dependencies: - '@bigcommerce/eslint-plugin': 1.4.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) - '@rushstack/eslint-patch': 1.10.5 - '@stylistic/eslint-plugin': 2.7.2(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/eslint-plugin': 8.28.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) - eslint: 8.57.1 - eslint-config-prettier: 9.1.0(eslint@8.57.1) - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-gettext: 1.2.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(jest@29.7.0)(typescript@5.8.3) - eslint-plugin-jest-dom: 5.5.0(eslint@8.57.1) - eslint-plugin-jest-formatting: 3.1.0(eslint@8.57.1) - eslint-plugin-jsdoc: 50.6.9(eslint@8.57.1) - eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) - eslint-plugin-prettier: 5.2.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.5.3) - eslint-plugin-react: 7.37.4(eslint@8.57.1) - eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) - eslint-plugin-switch-case: 1.1.2 - eslint-plugin-testing-library: 7.1.1(eslint@8.57.1)(typescript@5.8.3) - prettier: 3.5.3 - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - '@testing-library/dom' - - '@types/eslint' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - jest - - supports-color - '@bigcommerce/eslint-config@2.11.0(@types/eslint@9.6.1)(eslint@8.57.1)(jest@29.7.0)(typescript@5.8.3)': dependencies: '@bigcommerce/eslint-plugin': 1.4.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) @@ -9047,11 +9011,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.3.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.4.3': dependencies: tslib: 2.8.1 @@ -9781,7 +9740,7 @@ snapshots: '@napi-rs/wasm-runtime@0.2.7': dependencies: '@emnapi/core': 1.3.1 - '@emnapi/runtime': 1.3.1 + '@emnapi/runtime': 1.4.3 '@tybys/wasm-util': 0.9.0 optional: true @@ -9792,7 +9751,7 @@ snapshots: - bufferutil - utf-8-validate - '@next/env@15.4.0-canary.0': {} + '@next/env@15.4.0-canary.85': {} '@next/eslint-plugin-next@15.2.3': dependencies: @@ -9802,28 +9761,28 @@ snapshots: dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.4.0-canary.0': + '@next/swc-darwin-arm64@15.4.0-canary.85': optional: true - '@next/swc-darwin-x64@15.4.0-canary.0': + '@next/swc-darwin-x64@15.4.0-canary.85': optional: true - '@next/swc-linux-arm64-gnu@15.4.0-canary.0': + '@next/swc-linux-arm64-gnu@15.4.0-canary.85': optional: true - '@next/swc-linux-arm64-musl@15.4.0-canary.0': + '@next/swc-linux-arm64-musl@15.4.0-canary.85': optional: true - '@next/swc-linux-x64-gnu@15.4.0-canary.0': + '@next/swc-linux-x64-gnu@15.4.0-canary.85': optional: true - '@next/swc-linux-x64-musl@15.4.0-canary.0': + '@next/swc-linux-x64-musl@15.4.0-canary.85': optional: true - '@next/swc-win32-arm64-msvc@15.4.0-canary.0': + '@next/swc-win32-arm64-msvc@15.4.0-canary.85': optional: true - '@next/swc-win32-x64-msvc@15.4.0-canary.0': + '@next/swc-win32-x64-msvc@15.4.0-canary.85': optional: true '@nodelib/fs.scandir@2.1.5': @@ -9921,7 +9880,7 @@ snapshots: '@paulirish/trace_engine@0.0.53': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.26.6 + third-party-web: 0.27.0 '@pkgjs/parseargs@0.11.0': optional: true @@ -11223,20 +11182,18 @@ snapshots: dependencies: uncrypto: 0.1.3 - '@vercel/analytics@1.5.0(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/analytics@1.5.0(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) - '@vercel/kv@3.0.0': - dependencies: - '@upstash/redis': 1.35.0 + '@vercel/functions@2.2.0': {} - '@vercel/speed-insights@1.2.0(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/speed-insights@1.2.0(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) @@ -11747,10 +11704,6 @@ snapshots: esbuild: 0.25.1 load-tsconfig: 0.2.5 - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - bytes@3.1.2: {} c12@3.0.4(magicast@0.3.5): @@ -12558,8 +12511,8 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -12586,21 +12539,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.0 - eslint: 8.57.1 - get-tsconfig: 4.10.0 - is-bun-module: 1.3.0 - rspack-resolver: 1.2.2 - stable-hash: 0.0.5 - tinyglobby: 0.2.12 - optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -12617,17 +12555,6 @@ snapshots: - supports-color eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -12648,35 +12575,6 @@ snapshots: dependencies: gettext-parser: 4.2.0 - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.8 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 @@ -12688,7 +12586,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -14581,44 +14479,42 @@ snapshots: netmask@2.0.2: {} - next-auth@5.0.0-beta.25(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@6.9.16)(react@19.1.0): + next-auth@5.0.0-beta.25(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@6.9.16)(react@19.1.0): dependencies: '@auth/core': 0.37.2(nodemailer@6.9.16) - next: 15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 optionalDependencies: nodemailer: 6.9.16 - next-intl@4.1.0(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3): + next-intl@4.1.0(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3): dependencies: '@formatjs/intl-localematcher': 0.5.10 negotiator: 1.0.0 - next: 15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 use-intl: 4.1.0(react@19.1.0) optionalDependencies: typescript: 5.8.3 - next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@next/env': 15.4.0-canary.0 - '@swc/counter': 0.1.3 + '@next/env': 15.4.0-canary.85 '@swc/helpers': 0.5.15 - busboy: 1.6.0 - caniuse-lite: 1.0.30001707 + caniuse-lite: 1.0.30001721 postcss: 8.4.31 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) styled-jsx: 5.1.6(@babel/core@7.27.4)(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.4.0-canary.0 - '@next/swc-darwin-x64': 15.4.0-canary.0 - '@next/swc-linux-arm64-gnu': 15.4.0-canary.0 - '@next/swc-linux-arm64-musl': 15.4.0-canary.0 - '@next/swc-linux-x64-gnu': 15.4.0-canary.0 - '@next/swc-linux-x64-musl': 15.4.0-canary.0 - '@next/swc-win32-arm64-msvc': 15.4.0-canary.0 - '@next/swc-win32-x64-msvc': 15.4.0-canary.0 + '@next/swc-darwin-arm64': 15.4.0-canary.85 + '@next/swc-darwin-x64': 15.4.0-canary.85 + '@next/swc-linux-arm64-gnu': 15.4.0-canary.85 + '@next/swc-linux-arm64-musl': 15.4.0-canary.85 + '@next/swc-linux-x64-gnu': 15.4.0-canary.85 + '@next/swc-linux-x64-musl': 15.4.0-canary.85 + '@next/swc-win32-arm64-msvc': 15.4.0-canary.85 + '@next/swc-win32-x64-msvc': 15.4.0-canary.85 '@playwright/test': 1.52.0 sharp: 0.34.1 transitivePeerDependencies: @@ -14669,12 +14565,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.4.3(next@15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): + nuqs@2.4.3(next@15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): dependencies: mitt: 3.0.1 react: 19.1.0 optionalDependencies: - next: 15.4.0-canary.0(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.4.0-canary.85(@babel/core@7.27.4)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nwsapi@2.2.20: {} @@ -15708,7 +15604,7 @@ snapshots: sharp@0.34.1: dependencies: color: 4.2.3 - detect-libc: 2.0.3 + detect-libc: 2.0.4 semver: 7.7.2 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.1 @@ -15879,8 +15775,6 @@ snapshots: stdin-discarder@0.2.2: {} - streamsearch@1.1.0: {} - streamx@2.22.1: dependencies: fast-fifo: 1.3.2 @@ -16157,6 +16051,8 @@ snapshots: third-party-web@0.26.6: {} + third-party-web@0.27.0: {} + through@2.3.8: {} tinybench@2.9.0: {}