UNPKG

@invisiblecities/sanity-edge-fetcher

Version:

Lightweight, Edge Runtime-compatible Sanity client for Next.js and Vercel Edge Functions

1 lines 65.6 kB
{"version":3,"sources":["../config.ts","../node_modules/.pnpm/@vercel+stega@0.1.2/node_modules/@vercel/stega/dist/index.mjs","../src/stega.ts","../src/core.ts","../src/cache.ts","../src/enhanced.ts"],"sourcesContent":["/**\n * @file config.ts\n * @description Centralized configuration for Sanity Edge Fetcher\n * @author Invisible Cities Agency\n * @license MIT\n */\n\nimport { createEdgeSanityFetcher } from './src/core';\nimport { createCachedFetcher } from './src/cache';\nimport { edgeSanityFetchWithRetry } from './src/enhanced';\n\n/**\n * Sanity configuration from environment variables\n */\nexport const sanityConfig = {\n projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,\n apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2025-02-10',\n dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',\n token: process.env.SANITY_VIEWER_TOKEN,\n useCdn: process.env.NODE_ENV === 'production',\n} as const;\n\n/**\n * Cache configuration\n */\nexport const cacheConfig = {\n // Default TTLs for different content types (in seconds)\n ttl: {\n default: 60, // 1 minute\n static: 3600, // 1 hour for static content\n dynamic: 30, // 30 seconds for frequently changing content\n long: 86400, // 24 hours for rarely changing content\n },\n \n // Cache prefixes for organization\n prefixes: {\n page: 'page:',\n post: 'post:',\n author: 'author:',\n category: 'cat:',\n section: 'section:',\n global: 'global:',\n },\n \n // Layer configuration\n layers: {\n memory: {\n enabled: true,\n maxSize: 100,\n },\n redis: {\n enabled: !!(process.env.KV_REST_API_URL || process.env.UPSTASH_REDIS_REST_URL),\n url: process.env.KV_REST_API_URL || process.env.UPSTASH_REDIS_REST_URL,\n token: process.env.KV_REST_API_TOKEN || process.env.UPSTASH_REDIS_REST_TOKEN,\n readOnlyToken: process.env.KV_REST_API_READ_ONLY_TOKEN,\n },\n nextCache: {\n enabled: typeof window === 'undefined',\n },\n },\n} as const;\n\n/**\n * Rate limiting configuration\n */\nexport const rateLimitConfig = {\n minInterval: 100, // Minimum 100ms between requests\n maxRequestsPerSecond: 10,\n} as const;\n\n/**\n * Retry configuration (if p-retry is available)\n */\nexport const retryConfig = {\n retries: 3,\n minTimeout: 100,\n maxTimeout: 2000,\n factor: 2,\n} as const;\n\n/**\n * Real-time configuration\n */\nexport const realtimeConfig = {\n sse: {\n endpoint: '/api/sanity-updates',\n pollInterval: 5000, // 5 seconds\n heartbeatInterval: 30000, // 30 seconds\n },\n websocket: {\n endpoint: process.env.NEXT_PUBLIC_WEBSOCKET_URL || 'wss://your-worker.workers.dev/ws',\n reconnectDelay: 1000,\n maxReconnectAttempts: 5,\n },\n} as const;\n\n/**\n * Pre-configured fetchers for common use cases\n * NOTE: For Next.js apps, use the smart fetchers from the main export instead:\n * - sanityFetch() - Auto-detects draft mode\n * - sanityFetchWithFallback() - Smart fallback to drafts\n * - sanityFetchStatic() - Always CDN\n * - sanityFetchAuthenticated() - Always authenticated\n * \n * These are lower-level fetchers for advanced use cases:\n */\nexport const fetchers = {\n /**\n * Basic fetcher - no caching, no retry\n * Use for: One-off queries, testing\n */\n basic: createEdgeSanityFetcher(sanityConfig.dataset, false),\n \n /**\n * Authenticated fetcher - includes token for draft preview\n * Use for: Preview mode, draft content\n */\n authenticated: createEdgeSanityFetcher(sanityConfig.dataset, true),\n \n /**\n * Cached fetcher - multi-layer caching with default TTL\n * Use for: Most production queries\n */\n cached: createCachedFetcher(sanityConfig.dataset, {\n ttl: cacheConfig.ttl.default,\n useRedis: cacheConfig.layers.redis.enabled,\n useNextCache: cacheConfig.layers.nextCache.enabled,\n }),\n \n /**\n * Static content fetcher - long cache TTL\n * Use for: Global settings, rarely changing content\n */\n static: createCachedFetcher(sanityConfig.dataset, {\n ttl: cacheConfig.ttl.long,\n prefix: cacheConfig.prefixes.global,\n useRedis: cacheConfig.layers.redis.enabled,\n useNextCache: true,\n }),\n \n /**\n * Dynamic content fetcher - short cache TTL with retry\n * Use for: Frequently updated content, user-specific data\n */\n dynamic: createCachedFetcher(sanityConfig.dataset, {\n ttl: cacheConfig.ttl.dynamic,\n useRedis: cacheConfig.layers.redis.enabled,\n useNextCache: false, // Skip Next.js cache for dynamic content\n }),\n \n /**\n * Page fetcher - optimized for page data\n * Use for: Full page queries\n */\n page: createCachedFetcher(sanityConfig.dataset, {\n ttl: cacheConfig.ttl.static,\n prefix: cacheConfig.prefixes.page,\n useRedis: cacheConfig.layers.redis.enabled,\n useNextCache: true,\n }),\n \n /**\n * Section fetcher - for page sections/components\n * Use for: Individual page sections\n */\n section: createCachedFetcher(sanityConfig.dataset, {\n ttl: cacheConfig.ttl.default,\n prefix: cacheConfig.prefixes.section,\n useRedis: cacheConfig.layers.redis.enabled,\n useNextCache: true,\n }),\n} as const;\n\n/**\n * Helper to create a custom fetcher with merged config\n */\nexport function createCustomFetcher(options: {\n dataset?: string;\n ttl?: number;\n prefix?: string;\n useAuth?: boolean;\n useCache?: boolean;\n useRetry?: boolean;\n}) {\n const {\n dataset = sanityConfig.dataset,\n ttl = cacheConfig.ttl.default,\n prefix = '',\n useAuth = false,\n useCache = true,\n useRetry = false,\n } = options;\n \n if (!useCache && !useRetry) {\n return createEdgeSanityFetcher(dataset, useAuth);\n }\n \n if (useCache && !useRetry) {\n return createCachedFetcher(dataset, {\n ttl,\n prefix,\n useRedis: cacheConfig.layers.redis.enabled,\n useNextCache: cacheConfig.layers.nextCache.enabled,\n });\n }\n \n // For retry, we'd need to wrap the fetcher\n // This is a simplified version\n return async <T>(query: string, params?: any) => {\n const fetchFn = useCache \n ? createCachedFetcher(dataset, { ttl, prefix })\n : createEdgeSanityFetcher(dataset, useAuth);\n \n if (useRetry) {\n return edgeSanityFetchWithRetry<T>(\n { dataset, query, params, useAuth },\n retryConfig\n );\n }\n \n return fetchFn<T>(query, params);\n };\n}\n\n/**\n * Queries that should be warmed on startup\n */\nexport const warmupQueries = [\n // Global settings\n {\n query: '*[_type == \"siteSettings\"][0]',\n ttl: cacheConfig.ttl.long,\n fetcher: 'static',\n },\n // Navigation\n {\n query: '*[_type == \"navigation\"][0]',\n ttl: cacheConfig.ttl.long,\n fetcher: 'static',\n },\n // Recent posts\n {\n query: '*[_type == \"post\"] | order(_createdAt desc)[0..10]',\n ttl: cacheConfig.ttl.default,\n fetcher: 'cached',\n },\n // Homepage\n {\n query: '*[_type == \"page\" && slug.current == \"home\"][0]',\n ttl: cacheConfig.ttl.static,\n fetcher: 'page',\n },\n] as const;\n\n/**\n * Export all configurations for easy access\n */\nexport const config = {\n sanity: sanityConfig,\n cache: cacheConfig,\n rateLimit: rateLimitConfig,\n retry: retryConfig,\n realtime: realtimeConfig,\n fetchers,\n warmupQueries,\n} as const;\n\n// Default export for convenience\nexport default config;","var s={0:8203,1:8204,2:8205,3:8290,4:8291,5:8288,6:65279,7:8289,8:119155,9:119156,a:119157,b:119158,c:119159,d:119160,e:119161,f:119162},c={0:8203,1:8204,2:8205,3:65279},u=new Array(4).fill(String.fromCodePoint(c[0])).join(\"\"),m=String.fromCharCode(0);function E(t){let e=JSON.stringify(t);return`${u}${Array.from(e).map(r=>{let n=r.charCodeAt(0);if(n>255)throw new Error(`Only ASCII edit info can be encoded. Error attempting to encode ${e} on character ${r} (${n})`);return Array.from(n.toString(4).padStart(4,\"0\")).map(o=>String.fromCodePoint(c[o])).join(\"\")}).join(\"\")}`}function y(t){let e=JSON.stringify(t);return Array.from(e).map(r=>{let n=r.charCodeAt(0);if(n>255)throw new Error(`Only ASCII edit info can be encoded. Error attempting to encode ${e} on character ${r} (${n})`);return Array.from(n.toString(16).padStart(2,\"0\")).map(o=>String.fromCodePoint(s[o])).join(\"\")}).join(\"\")}function I(t){return!Number.isNaN(Number(t))||/[a-z]/i.test(t)&&!/\\d+(?:[-:\\/]\\d+){2}(?:T\\d+(?:[-:\\/]\\d+){1,2}(\\.\\d+)?Z?)?/.test(t)?!1:Boolean(Date.parse(t))}function T(t){try{new URL(t,t.startsWith(\"/\")?\"https://acme.com\":void 0)}catch{return!1}return!0}function C(t,e,r=\"auto\"){return r===!0||r===\"auto\"&&(I(t)||T(t))?t:`${t}${E(e)}`}var x=Object.fromEntries(Object.entries(c).map(t=>t.reverse())),g=Object.fromEntries(Object.entries(s).map(t=>t.reverse())),S=`${Object.values(s).map(t=>`\\\\u{${t.toString(16)}}`).join(\"\")}`,f=new RegExp(`[${S}]{4,}`,\"gu\");function G(t){let e=t.match(f);if(!!e)return h(e[0],!0)[0]}function $(t){let e=t.match(f);if(!!e)return e.map(r=>h(r)).flat()}function h(t,e=!1){let r=Array.from(t);if(r.length%2===0){if(r.length%4||!t.startsWith(u))return A(r,e)}else throw new Error(\"Encoded data has invalid length\");let n=[];for(let o=r.length*.25;o--;){let p=r.slice(o*4,o*4+4).map(d=>x[d.codePointAt(0)]).join(\"\");n.unshift(String.fromCharCode(parseInt(p,4)))}if(e){n.shift();let o=n.indexOf(m);return o===-1&&(o=n.length),[JSON.parse(n.slice(0,o).join(\"\"))]}return n.join(\"\").split(m).filter(Boolean).map(o=>JSON.parse(o))}function A(t,e){var d;let r=[];for(let i=t.length*.5;i--;){let a=`${g[t[i*2].codePointAt(0)]}${g[t[i*2+1].codePointAt(0)]}`;r.unshift(String.fromCharCode(parseInt(a,16)))}let n=[],o=[r.join(\"\")],p=10;for(;o.length;){let i=o.shift();try{if(n.push(JSON.parse(i)),e)return n}catch(a){if(!p--)throw a;let l=+((d=a.message.match(/\\sposition\\s(\\d+)$/))==null?void 0:d[1]);if(!l)throw a;o.unshift(i.substring(0,l),i.substring(l))}}return n}function _(t){var e;return{cleaned:t.replace(f,\"\"),encoded:((e=t.match(f))==null?void 0:e[0])||\"\"}}function O(t){return t&&JSON.parse(_(JSON.stringify(t)).cleaned)}export{f as VERCEL_STEGA_REGEX,y as legacyStegaEncode,O as vercelStegaClean,C as vercelStegaCombine,G as vercelStegaDecode,$ as vercelStegaDecodeAll,E as vercelStegaEncode,_ as vercelStegaSplit};\n","/**\n * @file stega.ts\n * @description Stega encoding support for visual editing with optional @vercel/stega dependency\n * @author Invisible Cities Agency\n * @license MIT\n */\n\n// Edge-safe ESM import for @vercel/stega (tiny, browser/edge-friendly)\n// This package has no Node dependencies and is safe in Edge bundles.\nimport { vercelStegaEncode as _vercelStegaEncode, vercelStegaCombine as _vercelStegaCombine, vercelStegaClean as _vercelStegaClean } from '@vercel/stega';\n// NOTE: Library API: vercelStegaEncode(json) -> invisible suffix, vercelStegaCombine(text, json, skip?) -> combined string\nconst vercelStegaEncode: ((metadata: any) => string) | undefined = _vercelStegaEncode as any;\nconst vercelStegaCombine: ((value: string, metadata: any, skip?: 'auto' | boolean) => string) | undefined = _vercelStegaCombine as any;\nconst vercelStegaClean: (<T = any>(value: T) => T) | undefined = _vercelStegaClean as any;\n\n/**\n * Stega configuration options\n */\nexport interface StegaConfig {\n enabled: boolean;\n studioUrl?: string;\n basePath?: string;\n filter?: (path: string) => boolean;\n projectId?: string;\n dataset?: string;\n}\n\n/**\n * Inline stega implementation for when @vercel/stega is not available\n * Uses Unicode code points to encode invisible metadata\n */\nconst STEGA_CODES = {\n // Tuple ensures index access returns number (not possibly undefined)\n base4: [8203, 8204, 8205, 65279] as const,\n hex: {\n 0: 8203, 1: 8204, 2: 8205, 3: 8290, 4: 8291, 5: 8288,\n 6: 65279, 7: 8289, 8: 119155, 9: 119156,\n a: 119157, b: 119158, c: 119159, d: 119160, e: 119161, f: 119162\n } as Record<string | number, number>\n};\n\nconst STEGA_PREFIX = new Array(4).fill(String.fromCodePoint(STEGA_CODES.base4[0])).join('');\n\n/**\n * Encode data as invisible Unicode characters (base4 encoding)\n */\nfunction encodeInvisibleBase4(data: any): string {\n const jsonStr = JSON.stringify(data);\n const encoded = Array.from(jsonStr).map(char => {\n const charCode = char.charCodeAt(0);\n if (charCode > 255) {\n throw new Error(`Only ASCII can be encoded. Error on character ${char} (${charCode})`);\n }\n // Convert to base-4 and encode as invisible characters\n return Array.from(charCode.toString(4).padStart(4, '0'))\n .map(digit => {\n // digit is one of '0' | '1' | '2' | '3'\n const idx = parseInt(digit, 10) as 0 | 1 | 2 | 3;\n return String.fromCodePoint(STEGA_CODES.base4[idx]);\n })\n .join('');\n }).join('');\n \n return `${STEGA_PREFIX}${encoded}`;\n}\n\n/**\n * Check if a value should skip stega encoding\n */\nfunction shouldSkipEncoding(value: string): boolean {\n // Skip dates and URLs as they shouldn't be encoded\n const isDate = /\\d+(?:[-:\\/]\\d+){2}(?:T\\d+(?:[-:\\/]\\d+){1,2}(\\.\\d+)?Z?)?/.test(value);\n const isUrl = (() => {\n try {\n new URL(value, value.startsWith('/') ? 'https://example.com' : undefined);\n return true;\n } catch {\n return false;\n }\n })();\n // Skip Sanity asset/file id strings like image-<hash>-<WxH>-<ext>\n const isSanityAssetId = /^(image|file)-[A-Za-z0-9]+-\\d+x\\d+-[A-Za-z0-9]+$/.test(value);\n \n return isDate || isUrl || isSanityAssetId;\n}\n\n/**\n * Determine if the current path points to structured fields that must not be stega-encoded\n */\nfunction isStructuredPath(path: Array<string | number>): boolean {\n if (!path.length) return false;\n const last = String(path[path.length - 1]);\n // Any Sanity meta or reference/slug/asset/urlish fields\n const disallowed = new Set([\n '_id', '_ref', '_key', '_type',\n 'slug', 'current',\n 'asset', 'path',\n 'href', 'url', 'src'\n ]);\n if (disallowed.has(last)) return true;\n // If parent key is 'asset', skip child values as well\n if (path.length >= 2 && String(path[path.length - 2]) === 'asset') return true;\n return false;\n}\n\n/**\n * Encode stega metadata into a string value\n */\nfunction encodeStegaString(value: string, metadata: any, config: StegaConfig): string {\n if (!config.enabled || !metadata) {\n return value;\n }\n \n // Skip encoding for dates and URLs\n if (shouldSkipEncoding(value)) {\n return value;\n }\n \n // Skip encoding when there is no visible content to preserve\n if (typeof value !== 'string' || value.trim().length === 0) {\n return value;\n }\n\n // Skip if already stega-encoded to avoid double payloads\n try {\n if (vercelStegaClean && vercelStegaClean(value) !== value) {\n return value;\n }\n } catch {\n // ignore and continue\n }\n \n // Use @vercel/stega if available, otherwise use inline implementation\n if (vercelStegaEncode) {\n // Prefer combine when available; fallback to appending encoded invisible suffix\n if (vercelStegaCombine) return vercelStegaCombine(value, metadata, 'auto');\n return `${value}${vercelStegaEncode(metadata)}`;\n }\n \n // Inline implementation\n return `${value}${encodeInvisibleBase4(metadata)}`;\n}\n\n/**\n * Recursively encode stega metadata into result data\n */\nfunction encodeStegaInResult(\n data: any,\n sourceMap: any,\n config: StegaConfig,\n path: Array<string | number> = []\n): any {\n if (!config.enabled || !sourceMap) {\n return data;\n }\n \n // Handle null/undefined\n if (data == null) {\n return data;\n }\n \n // Handle strings\n if (typeof data === 'string') {\n // Never encode structured/system fields\n if (isStructuredPath(path)) return data;\n const metadata = resolveSourceMapForPath(sourceMap, path, config);\n // Only encode mapped leaf values\n if (metadata && (metadata.type === undefined || metadata.type === 'value')) {\n return encodeStegaString(data, metadata, config);\n }\n return data;\n }\n \n // Handle arrays\n if (Array.isArray(data)) {\n return data.map((item, index) => \n encodeStegaInResult(item, sourceMap, config, [...path, index])\n );\n }\n \n // Handle objects\n if (typeof data === 'object') {\n const result: any = {};\n for (const key in data) {\n if (data.hasOwnProperty(key)) {\n result[key] = encodeStegaInResult(\n data[key],\n sourceMap,\n config,\n [...path, key]\n );\n }\n }\n return result;\n }\n \n // Return primitives as-is\n return data;\n}\n\n/**\n * Resolve source map metadata for a given path\n */\nfunction resolveSourceMapForPath(sourceMap: any, path: Array<string | number>, config?: StegaConfig): any {\n if (!sourceMap?.mappings) {\n return null;\n }\n \n // Convert path to JSONPath format for lookup\n const jsonPath = `$${path.map(segment => \n typeof segment === 'number' ? `[${segment}]` : `['${segment}']`\n ).join('')}`;\n \n // Look for exact match or closest parent\n if (sourceMap.mappings[jsonPath]) {\n const mapping = sourceMap.mappings[jsonPath];\n const studioUrl = sourceMap.studioUrl || config?.studioUrl;\n\n // Attempt to build a Studio Edit Intent URL: /intent/edit/id=<docId>;path=<fieldPath>\n let href: string | undefined;\n try {\n const src = mapping?.source;\n const docId = typeof src?.document === 'number' ? sourceMap.documents?.[src.document]?._id : undefined;\n const fieldPath = typeof src?.path === 'number' ? sourceMap.paths?.[src.path] : undefined;\n\n if (studioUrl && docId) {\n // Normalize studio base to exclude trailing /presentation if present\n const studioBase = String(studioUrl).replace(/\\/?presentation\\/?$/, '').replace(/\\/$/, '');\n const pathParam = fieldPath ? `;path=${encodeURIComponent(fieldPath)}` : '';\n href = `${studioBase}/intent/edit/id=${encodeURIComponent(docId)}${pathParam}`;\n }\n } catch {\n // ignore href build errors\n }\n\n return {\n // Canonical hints for overlay decoders\n _origin: 'sanity',\n projectId: config?.projectId,\n dataset: config?.dataset,\n studioUrl,\n path: jsonPath,\n source: sourceMap.source,\n href,\n ...mapping,\n };\n }\n \n // Try to find parent paths\n let currentPath = jsonPath;\n while (currentPath.includes('[') || currentPath.includes('.')) {\n const lastIndex = Math.max(\n currentPath.lastIndexOf('['),\n currentPath.lastIndexOf('.')\n );\n if (lastIndex === -1) break;\n \n currentPath = currentPath.substring(0, lastIndex);\n if (sourceMap.mappings[currentPath]) {\n return {\n _origin: 'sanity',\n projectId: config?.projectId,\n dataset: config?.dataset,\n studioUrl: sourceMap.studioUrl,\n source: sourceMap.source,\n path: currentPath,\n ...sourceMap.mappings[currentPath]\n };\n }\n }\n \n return null;\n}\n\n/**\n * Get the studio URL from environment or config\n */\nfunction getStudioUrl(config?: Partial<StegaConfig>): string | undefined {\n if (config?.studioUrl) {\n return config.studioUrl;\n }\n \n // Try environment variables\n const envStudioUrl = process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || \n process.env.SANITY_STUDIO_URL;\n \n if (envStudioUrl) {\n return envStudioUrl;\n }\n \n // Default based on environment\n if (process.env.NODE_ENV === 'development') {\n // Prefer HTTPS proxy for cookie/iframe parity with Presentation\n return 'https://localhost:3334/presentation';\n }\n \n return undefined;\n}\n\n/**\n * Check if stega should be enabled\n */\nexport function shouldEnableStega(isDraftMode: boolean, config?: Partial<StegaConfig>): boolean {\n // Explicit config takes precedence\n if (config?.enabled !== undefined) {\n return config.enabled;\n }\n \n // Default: enable in draft mode or development\n return isDraftMode || process.env.NODE_ENV === 'development';\n}\n\n/**\n * Process Sanity response to handle stega encoding\n */\nexport function processStegaResponse(\n response: any,\n isDraftMode: boolean,\n config?: Partial<StegaConfig> | StegaConfig\n): any {\n // If stega is not enabled, return just the result\n if (!shouldEnableStega(isDraftMode, config)) {\n return response.result;\n }\n \n // When stega is enabled, we need to encode the source map into the result\n const result = response.result;\n const sourceMap = response.resultSourceMap;\n \n if (sourceMap && config?.enabled) {\n // Add studio URL to source map for visual editing\n const studioUrl = getStudioUrl(config);\n if (studioUrl && sourceMap) {\n sourceMap.studioUrl = studioUrl;\n }\n \n // Encode stega metadata into the result\n const fullConfig = buildStegaConfig(isDraftMode, config);\n return encodeStegaInResult(result, sourceMap, fullConfig);\n }\n \n return result;\n}\n\n/**\n * Build stega configuration from environment and options\n */\nexport function buildStegaConfig(\n isDraftMode: boolean,\n options?: Partial<StegaConfig>\n): StegaConfig {\n const config: Partial<StegaConfig> = options || {};\n const enabled = shouldEnableStega(isDraftMode, config);\n \n const studioUrl = getStudioUrl(config);\n const projectId = config.projectId || process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;\n const dataset = config.dataset || process.env.NEXT_PUBLIC_SANITY_DATASET;\n\n return {\n enabled,\n ...(studioUrl !== undefined ? { studioUrl } : {}),\n ...(config.basePath !== undefined ? { basePath: config.basePath } : {}),\n ...(config.filter !== undefined ? { filter: config.filter } : {}),\n ...(projectId !== undefined ? { projectId } : {}),\n ...(dataset !== undefined ? { dataset } : {}),\n } as StegaConfig;\n}\n\n/**\n * Clean stega-encoded strings (remove invisible characters)\n * Useful for comparing values or using in business logic\n */\nexport function stegaClean<T = any>(value: T): T {\n if (vercelStegaClean) {\n return vercelStegaClean(value);\n }\n \n // Inline implementation - remove all stega Unicode characters\n const stegaChars = Object.values(STEGA_CODES.hex)\n .map(code => `\\\\u{${code.toString(16)}}`)\n .join('');\n const stegaRegex = new RegExp(`[${stegaChars}]{4,}`, 'gu');\n \n const cleanString = (str: string) => str.replace(stegaRegex, '');\n \n // Recursively clean all strings in the value\n if (typeof value === 'string') {\n return cleanString(value) as T;\n }\n \n if (Array.isArray(value)) {\n return value.map(item => stegaClean(item)) as T;\n }\n \n if (value && typeof value === 'object') {\n const cleaned: any = {};\n for (const key in value) {\n if ((value as any).hasOwnProperty(key)) {\n cleaned[key] = stegaClean((value as any)[key]);\n }\n }\n return cleaned;\n }\n \n return value;\n}","/**\n * @file core.ts\n * @description Next.js-native, edge-compatible Sanity data fetcher with stega support\n * @author Invisible Cities Agency\n * @license MIT\n */\n\nimport { buildStegaConfig, processStegaResponse, type StegaConfig } from './stega';\n\n// Helper to check if draft mode is enabled in Next.js\nasync function isDraftModeEnabled(): Promise<boolean> {\n try {\n // Dynamic import to avoid build issues in non-Next.js environments\n const { draftMode } = await import('next/headers');\n const draft = await draftMode();\n return draft.isEnabled;\n } catch {\n // Not in Next.js or draft mode not available\n return false;\n }\n}\n\n// Detect if current request should enable stega/visual editing overlays\n// Uses Next.js headers/cookies if available, but degrades gracefully when not running in Next\nexport async function detectStegaRequest(options?: {\n /** Feature flag cookie name set by Studio or a toggle endpoint (default: 'ic_stega') */\n cookieName?: string;\n /** Optional custom header to allow one-shot enablement (default checks common names) */\n headerName?: string;\n /** Studio URL or origin to validate Referer against; defaults to NEXT_PUBLIC_SANITY_STUDIO_URL */\n studioUrl?: string;\n /** Force enable regardless of environment signals */\n forceEnable?: boolean;\n /** Force disable regardless of environment signals */\n forceDisable?: boolean;\n}): Promise<boolean> {\n if (options?.forceEnable) return true;\n if (options?.forceDisable) return false;\n\n // Defaults\n const cookieName = options?.cookieName ?? 'ic_stega';\n const studioEnv = options?.studioUrl || process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || process.env.SANITY_STUDIO_URL;\n\n try {\n // Dynamic import to avoid hard Next.js dependency\n const mod = await import('next/headers');\n const cookies = (mod as any).cookies?.bind(mod);\n const headers = (mod as any).headers?.bind(mod);\n\n const draft = (await ((mod as any).draftMode?.() ?? { isEnabled: false }));\n if (draft?.isEnabled) return true;\n\n // Check cookie flag\n const hasCookie = typeof cookies === 'function' ? Boolean(cookies().get(cookieName)?.value) : false;\n if (hasCookie) return true;\n\n // Check custom or common headers\n const hdrs = typeof headers === 'function' ? headers() : undefined;\n const headerName = options?.headerName;\n const headerCandidates = [headerName, 'x-ic-stega', 'x-sanity-present', 'x-sanity-preview'].filter(Boolean) as string[];\n if (hdrs) {\n for (const name of headerCandidates) {\n const v = hdrs.get(name);\n if (v && v !== '0' && v.toLowerCase() !== 'false') return true;\n }\n }\n\n // Referer origin check against Studio origin\n if (hdrs && studioEnv) {\n const ref = hdrs.get('referer');\n if (ref) {\n try {\n const refererOrigin = new URL(ref).origin;\n const studioOrigin = new URL(studioEnv).origin;\n if (refererOrigin === studioOrigin) return true;\n } catch {\n // ignore URL parse errors\n }\n }\n }\n } catch {\n // Not in Next.js environment; fall through\n }\n\n // Fallback: enable in development if explicitly configured via env\n if (process.env.NEXT_PUBLIC_ENABLE_STEGA === '1') return true;\n return false;\n}\n\n// Get config from environment variables\nconst getProjectId = () => {\n const id = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;\n if (!id) {\n throw new Error('NEXT_PUBLIC_SANITY_PROJECT_ID environment variable is required');\n }\n return id;\n};\n\nconst apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2025-02-10';\n\n// Get the viewer token - check multiple possible env vars\nconst getViewerToken = () => {\n return process.env.SANITY_VIEWER_TOKEN || \n process.env.SANITY_API_READ_TOKEN ||\n process.env.NEXT_PUBLIC_SANITY_VIEWER_TOKEN;\n};\n\nexport type QueryParams = Record<string, string | number | boolean | null | undefined | Array<string | number | boolean>>;\n\nexport interface EdgeSanityFetchOptions {\n /** Sanity dataset to query (e.g., 'production', 'staging') */\n dataset: string;\n /** GROQ query string */\n query: string;\n /** Optional query parameters for GROQ placeholders */\n params?: QueryParams;\n /** Whether to use Sanity's CDN (faster but no auth) */\n useCdn?: boolean;\n /** Whether to include auth token for draft preview access */\n useAuth?: boolean;\n /** Stega configuration for visual editing */\n stega?: Partial<StegaConfig>;\n}\n\n/**\n * Simple rate limiter to prevent 429 errors\n * @internal\n */\nclass EdgeRateLimiter {\n private lastRequest = 0;\n private readonly minInterval = 100; // 10 req/sec max\n\n async throttle(): Promise<void> {\n const now = Date.now();\n const timeSinceLastRequest = now - this.lastRequest;\n\n if (timeSinceLastRequest < this.minInterval) {\n const delay = this.minInterval - timeSinceLastRequest;\n await new Promise(resolve => setTimeout(resolve, delay));\n }\n\n this.lastRequest = Date.now();\n }\n}\n\nconst rateLimiter = new EdgeRateLimiter();\n\n/**\n * Fetches data from Sanity using native fetch API\n * Compatible with Edge Runtime and static generation\n */\nexport async function edgeSanityFetch<T>({\n dataset,\n query,\n params = {},\n useCdn = false,\n useAuth = false,\n stega\n}: EdgeSanityFetchOptions): Promise<T> {\n // Apply rate limiting\n await rateLimiter.throttle();\n\n // Build the query URL\n const projectId = getProjectId();\n\n // Determine if we need source maps for stega (before choosing base URL)\n const isDraftMode = useAuth;\n const stegaConfig = buildStegaConfig(isDraftMode, stega);\n\n // When stega is enabled, force non-CDN API to ensure resultSourceMap is returned\n const useCdnEffective = stegaConfig.enabled ? false : useCdn;\n const baseUrl = useCdnEffective\n ? `https://${projectId}.apicdn.sanity.io`\n : `https://${projectId}.api.sanity.io`;\n\n const url = new URL(`${baseUrl}/v${apiVersion}/data/query/${dataset}`);\n url.searchParams.set('query', query);\n \n if (useAuth) {\n // Use 'previewDrafts' perspective to see draft documents merged with published\n url.searchParams.set('perspective', 'previewDrafts');\n }\n \n // Request source maps when stega is enabled\n if (stegaConfig.enabled) {\n url.searchParams.set('resultSourceMap', 'true');\n }\n\n // Add parameters\n Object.entries(params).forEach(([key, value]) => {\n url.searchParams.set(`$${key}`, JSON.stringify(value));\n });\n\n // Build headers\n const headers: Record<string, string> = {\n 'Accept': 'application/json',\n };\n\n // Use env var for auth to maintain static generation compatibility\n if (useAuth) {\n const envToken = getViewerToken();\n if (envToken) {\n headers['Authorization'] = `Bearer ${envToken}`;\n }\n }\n \n const response = await fetch(url.toString(), {\n method: 'GET',\n headers,\n });\n\n if (!response.ok) {\n await response.text(); // Consume the body to prevent memory leak\n throw new Error(`Sanity fetch failed: ${response.status} ${response.statusText}`);\n }\n\n const data = await response.json();\n \n // Process response with stega support (reuse stegaConfig from above)\n return processStegaResponse(data, isDraftMode, stegaConfig);\n}\n\n/**\n * Factory function to create a typed Sanity fetcher for a given dataset\n */\nexport function createEdgeSanityFetcher(dataset: string, useAuth = false, stega?: Partial<StegaConfig>) {\n return <T>(query: string, params?: QueryParams) => {\n const options: EdgeSanityFetchOptions = {\n dataset,\n query,\n useAuth,\n ...(stega !== undefined ? { stega } : {}),\n ...(params !== undefined ? { params } : {}),\n };\n return edgeSanityFetch<T>(options);\n };\n}\n\n/**\n * Next.js-aware Sanity fetcher that automatically handles draft mode\n * This is the primary fetcher for Next.js applications\n * \n * @example\n * const data = await sanityFetch('*[_type == \"post\"][0]');\n */\nexport async function sanityFetch<T = unknown>(\n query: string,\n params?: QueryParams,\n options?: {\n dataset?: string;\n /** Override automatic draft mode detection */\n forceAuth?: boolean;\n /** Stega configuration for visual editing */\n stega?: Partial<StegaConfig>;\n }\n): Promise<T> {\n const dataset = options?.dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production';\n const useAuth = options?.forceAuth ?? await isDraftModeEnabled();\n \n return edgeSanityFetch<T>({\n dataset,\n query,\n ...(params !== undefined ? { params } : {}),\n useCdn: !useAuth, // Use CDN when not authenticated\n useAuth,\n stega: options?.stega || { enabled: useAuth }, // Enable stega in draft mode by default\n });\n}\n\n/**\n * Presentation-aware hybrid fetcher\n * Automatically enables authenticated fetch + stega overlays when a Studio/Presentation\n * signal is detected (draftMode cookie, feature flag cookie, referer from Studio, or header).\n * Otherwise uses fast CDN fetch with no stega.\n */\nexport async function sanityFetchHybrid<T = unknown>(\n query: string,\n params?: QueryParams,\n options?: {\n dataset?: string;\n /** Optional stega options to merge with defaults */\n stega?: Partial<StegaConfig>;\n /** Detection overrides */\n cookieName?: string;\n headerName?: string;\n studioUrl?: string;\n forceEnableStega?: boolean;\n forceDisableStega?: boolean;\n }\n): Promise<T> {\n const dataset = options?.dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production';\n const enableStega = await detectStegaRequest({\n ...(options?.cookieName !== undefined ? { cookieName: options.cookieName } : {}),\n ...(options?.headerName !== undefined ? { headerName: options.headerName } : {}),\n ...(options?.studioUrl !== undefined ? { studioUrl: options.studioUrl } : {}),\n ...(options?.forceEnableStega !== undefined ? { forceEnable: options.forceEnableStega } : {}),\n ...(options?.forceDisableStega !== undefined ? { forceDisable: options.forceDisableStega } : {}),\n });\n\n return edgeSanityFetch<T>({\n dataset,\n query,\n ...(params !== undefined ? { params } : {}),\n useCdn: !enableStega,\n useAuth: enableStega,\n stega: { enabled: enableStega, ...(options?.stega || {}) },\n });\n}\n\n/**\n * Sanity fetcher with automatic draft fallback\n * Tries to fetch published content first, falls back to drafts if empty\n * Perfect for singleton documents that might only exist as drafts\n * \n * @example\n * const page = await sanityFetchWithFallback('*[_type == \"page\" && slug.current == $slug][0]', { slug });\n */\nexport async function sanityFetchWithFallback<T = unknown>(\n query: string,\n params?: QueryParams,\n options?: {\n dataset?: string;\n /** Log when falling back to drafts */\n logFallback?: boolean;\n /** Stega configuration for visual editing */\n stega?: Partial<StegaConfig>;\n }\n): Promise<T> {\n const dataset = options?.dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production';\n const isNextDraftMode = await isDraftModeEnabled();\n \n // If already in draft mode, just use authenticated fetch\n if (isNextDraftMode) {\n return edgeSanityFetch<T>({\n dataset,\n query,\n ...(params !== undefined ? { params } : {}),\n useCdn: false,\n useAuth: true,\n stega: options?.stega || { enabled: true },\n });\n }\n \n // Try published content first\n const publishedResult = await edgeSanityFetch<T>({\n dataset,\n query,\n ...(params !== undefined ? { params } : {}),\n useCdn: true,\n useAuth: false,\n });\n \n // If we got content, return it\n if (publishedResult) {\n return publishedResult;\n }\n \n // No published content, try drafts\n if (options?.logFallback !== false && process.env.NODE_ENV !== 'production') {\n console.warn('[sanityFetchWithFallback] No published content found, checking for drafts...');\n }\n \n const draftResult = await edgeSanityFetch<T>({\n dataset,\n query,\n ...(params !== undefined ? { params } : {}),\n useCdn: false,\n useAuth: true,\n stega: options?.stega || { enabled: true },\n });\n \n if (draftResult && options?.logFallback !== false && process.env.NODE_ENV !== 'production') {\n console.warn('[sanityFetchWithFallback] Draft content found and returned');\n }\n \n return draftResult;\n}\n\n/**\n * Static content fetcher - always uses CDN, never authenticates\n * Use for global settings and content that rarely changes\n * \n * @example\n * const settings = await sanityFetchStatic('*[_type == \"siteSettings\"][0]');\n */\nexport async function sanityFetchStatic<T = unknown>(\n query: string,\n params?: QueryParams,\n dataset?: string\n): Promise<T> {\n return edgeSanityFetch<T>({\n dataset: dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',\n query,\n ...(params !== undefined ? { params } : {}),\n useCdn: true,\n useAuth: false,\n });\n}\n\n/**\n * Authenticated fetcher - always uses authentication\n * Use when you need to ensure draft content is visible\n * \n * @example\n * const drafts = await sanityFetchAuthenticated('*[_type == \"post\" && _id in path(\"drafts.**\")]');\n */\nexport async function sanityFetchAuthenticated<T = unknown>(\n query: string,\n params?: QueryParams,\n dataset?: string\n): Promise<T> {\n return edgeSanityFetch<T>({\n dataset: dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',\n query,\n ...(params !== undefined ? { params } : {}),\n useCdn: false,\n useAuth: true,\n });\n}","/**\n * @file cache.ts\n * @description Multi-layer caching for Sanity Edge Fetcher\n * @author Invisible Cities Agency\n * @license MIT\n */\n\nimport { edgeSanityFetch, type EdgeSanityFetchOptions, type QueryParams } from './core';\nimport { Redis } from '@upstash/redis';\n\n// Check if Upstash Redis is configured\nconst REDIS_URL = process.env.KV_REST_API_URL || process.env.UPSTASH_REDIS_REST_URL;\nconst REDIS_TOKEN = process.env.KV_REST_API_TOKEN || process.env.UPSTASH_REDIS_REST_TOKEN;\nconst REDIS_READ_ONLY_TOKEN = process.env.KV_REST_API_READ_ONLY_TOKEN;\n\nconst isRedisConfigured = !!(REDIS_URL && (REDIS_TOKEN || REDIS_READ_ONLY_TOKEN));\n\n// Initialize Redis client if configured\nlet redis: Redis | null = null;\nlet redisWriter: Redis | null = null;\n\nif (isRedisConfigured && REDIS_URL && (REDIS_TOKEN || REDIS_READ_ONLY_TOKEN)) {\n try {\n redis = new Redis({\n url: REDIS_URL,\n token: (REDIS_READ_ONLY_TOKEN || REDIS_TOKEN) as string,\n automaticDeserialization: true,\n });\n \n // Separate writer client if write token available\n if (REDIS_TOKEN) {\n redisWriter = new Redis({\n url: REDIS_URL,\n token: REDIS_TOKEN,\n automaticDeserialization: true,\n });\n } else {\n redisWriter = redis;\n }\n } catch {\n // Failed to initialize Redis client\n redis = null;\n redisWriter = null;\n }\n}\n\ninterface CacheEntry<T> {\n value: T;\n timestamp: number;\n validUntil: number;\n}\n\ninterface CachedFetchOptions extends EdgeSanityFetchOptions {\n /** Cache configuration */\n cache?: {\n /** Time to live in seconds */\n ttl?: number;\n /** Cache key prefix */\n prefix?: string;\n /** Force cache refresh */\n force?: boolean;\n /** Enable Redis caching if available */\n useRedis?: boolean;\n /** Enable Next.js cache */\n useNextCache?: boolean;\n };\n}\n\n/**\n * Generate cache key from query and params\n */\nfunction generateCacheKey(\n dataset: string,\n query: string,\n params?: QueryParams\n): string {\n const baseKey = `sanity:${dataset}:${query}`;\n if (!params || Object.keys(params).length === 0) {\n return baseKey;\n }\n \n // Sort params for consistent key generation\n const sortedParams = Object.keys(params)\n .sort()\n .map(key => `${key}=${JSON.stringify(params[key])}`)\n .join('&');\n \n return `${baseKey}:${sortedParams}`;\n}\n\n/**\n * In-memory LRU cache for edge runtime\n */\nclass MemoryCache {\n private cache = new Map<string, CacheEntry<unknown>>();\n private maxSize = 100;\n \n get<T>(key: string): T | null {\n const entry = this.cache.get(key);\n if (!entry) return null;\n \n if (Date.now() > entry.validUntil) {\n this.cache.delete(key);\n return null;\n }\n \n // Move to end (LRU)\n this.cache.delete(key);\n this.cache.set(key, entry);\n \n return entry.value as T;\n }\n \n set<T>(key: string, value: T, ttl: number): void {\n // Evict oldest if at capacity\n if (this.cache.size >= this.maxSize && !this.cache.has(key)) {\n const firstKey = this.cache.keys().next().value;\n if (firstKey !== undefined) {\n this.cache.delete(firstKey);\n }\n }\n \n this.cache.set(key, {\n value,\n timestamp: Date.now(),\n validUntil: Date.now() + (ttl * 1000)\n });\n }\n \n delete(key: string): void {\n this.cache.delete(key);\n }\n \n clear(): void {\n this.cache.clear();\n }\n \n size(): number {\n return this.cache.size;\n }\n}\n\n// Global memory cache instance\nconst memoryCache = new MemoryCache();\n\n/**\n * Fetches data from Sanity with multi-layer caching\n * \n * Cache layers (in order):\n * 1. In-memory LRU cache (fastest, ~1ms)\n * 2. Upstash Redis (if configured, ~10-30ms)\n * 3. Origin fetch with Next.js cache\n */\nexport async function cachedSanityFetch<T>(\n options: CachedFetchOptions\n): Promise<T> {\n const {\n dataset,\n query,\n params,\n cache = {}\n } = options;\n \n const {\n ttl = 60, // 1 minute default\n prefix = '',\n force = false,\n useRedis = true\n } = cache;\n \n const cacheKey = prefix + generateCacheKey(dataset, query, params);\n \n // Layer 1: Memory cache (unless force refresh)\n if (!force) {\n const memoryResult = memoryCache.get<T>(cacheKey);\n if (memoryResult !== null) {\n // Cache hit from memory\n return memoryResult;\n }\n }\n \n // Layer 2: Redis cache (if configured and enabled)\n if (!force && useRedis && redis) {\n try {\n const redisEntry = await redis.get<CacheEntry<T>>(cacheKey);\n if (redisEntry && Date.now() <= redisEntry.validUntil) {\n // Cache hit from Redis\n \n // Populate memory cache\n memoryCache.set(cacheKey, redisEntry.value, ttl);\n \n return redisEntry.value;\n }\n } catch {\n // Redis cache read error, continue to fetch from origin\n // Continue to fetch from origin\n }\n }\n \n // Layer 3: Fetch from origin\n // Cache miss, fetching from origin\n \n // Build safe options for edge fetch (remove cache, avoid undefined params)\n const { cache: _cache, params: _params, ...rest } = options;\n const edgeOptions = (params !== undefined)\n ? { ...rest, params }\n : { ...rest };\n \n // Fetch from origin (Next.js cache removed for edge compatibility)\n const result = await edgeSanityFetch<T>(edgeOptions as EdgeSanityFetchOptions);\n \n // Populate caches\n memoryCache.set(cacheKey, result, ttl);\n \n if (useRedis && redisWriter) {\n try {\n const entry: CacheEntry<T> = {\n value: result,\n timestamp: Date.now(),\n validUntil: Date.now() + (ttl * 1000)\n };\n await redisWriter.set(cacheKey, entry, { ex: ttl });\n } catch {\n // Redis cache write error, continue without caching\n // Continue without caching\n }\n }\n \n return result;\n}\n\n/**\n * Create a cached fetcher with default options\n */\nexport function createCachedFetcher(\n dataset: string,\n defaultCacheOptions?: CachedFetchOptions['cache']\n) {\n return <T>(\n query: string,\n params?: QueryParams,\n cacheOverrides?: CachedFetchOptions['cache']\n ) => {\n return cachedSanityFetch<T>({\n dataset,\n query,\n ...(params !== undefined ? { params } : {}),\n cache: { ...(defaultCacheOptions || {}), ...(cacheOverrides || {}) }\n });\n };\n}\n\n/**\n * Clear caches for a specific dataset or pattern\n */\nexport async function clearSanityCache(options?: {\n dataset?: string;\n pattern?: string;\n clearMemory?: boolean;\n clearRedis?: boolean;\n}): Promise<void> {\n const {\n dataset,\n pattern,\n clearMemory = true,\n clearRedis = true\n } = options || {};\n \n // Clear memory cache\n if (clearMemory) {\n if (!dataset && !pattern) {\n memoryCache.clear();\n } else {\n // Note: Memory cache doesn't support pattern matching\n // Would need to iterate all keys for pattern support\n // Pattern-based memory cache clearing not implemented\n }\n }\n \n // Clear Redis cache\n if (clearRedis && redisWriter && redis) {\n try {\n const keyPattern = pattern || (dataset ? `sanity:${dataset}:*` : 'sanity:*');\n const keys = await redis.keys(keyPattern);\n if (keys.length > 0) {\n await redisWriter.del(...keys);\n }\n } catch {\n // Failed to clear Redis cache\n }\n }\n}\n\n/**\n * Warm cache by pre-fetching common queries\n */\nexport async function warmSanityCache(\n queries: Array<{\n dataset: string;\n query: string;\n params?: QueryParams;\n ttl?: number;\n }>\n): Promise<void> {\n await Promise.all(\n queries.map(({ dataset, query, params, ttl }) =>\n cachedSanityFetch({\n dataset,\n query,\n ...(params !== undefined ? { params } : {}),\n cache: { ...(ttl !== undefined ? { ttl } : {}) }\n }).catch(() => {\n // Failed to warm cache for query\n })\n )\n );\n}\n\n// Export cache status utility\nexport function getCacheStatus() {\n return {\n memory: {\n available: true,\n size: memoryCache.size()\n },\n redis: {\n available: isRedisConfigured && redis !== null,\n configured: isRedisConfigured,\n url: REDIS_URL ? new URL(REDIS_URL).hostname : null\n },\n nextCache: {\n available: typeof window === 'undefined'\n }\n };\n}","/**\n * @file enhanced.ts\n * @description Enhanced Sanity fetcher with retry and real-time capabilities\n * @author Invisible Cities Agency\n * @license MIT\n */\n\nimport { edgeSanityFetch, type EdgeSanityFetchOptions, type QueryParams } from './core';\n\n// Type for p-retry module\ninterface PRetryModule {\n default: <T>(\n input: () => Promise<T>,\n options?: {\n retries?: number;\n minTimeout?: number;\n maxTimeout?: number;\n onFailedAttempt?: (error: { attemptNumber: number; retriesLeft: number }) => void;\n }\n ) => Promise<T>;\n}\n\n// Dynamic imports for optional dependencies\nlet pRetryModule: PRetryModule | null = null;\n\n// Lazy load optional dependencies\nconst loadPRetry = async (): Promise<PRetryModule | null> => {\n if (pRetryModule) return pRetryModule;\n try {\n pRetryModule = await import('p-retry');\n return pRetryModule;\n } catch {\n return null;\n }\n};\n\n/**\n * Fetches data from Sanity with automatic retry support\n * Falls back to single attempt if p-retry not installed\n */\nexport async function edgeSanityFetchWithRetry<T>(\n options: EdgeSanityFetchOptions,\n retryOptions?: {\n retries?: number;\n minTimeout?: number;\n maxTimeout?: number;\n }\n): Promise<T> {\n // Try to load p-retry if available\n const retry = await loadPRetry();\n \n // If p-retry not available, fall back to basic fetch\n if (!retry) {\n return edgeSanityFetch<T>(options);\n }\n\n const defaultRetryOptions = {\n retries: 3,\n minTimeout: 100,\n maxTimeout: 2000,\n onFailedAttempt: (error: { attemptNumber: number; retriesLeft: number }) => {\n // Sanity fetch attempt failed, will retry\n void error.attemptNumber;\n void error.retriesLeft;\n }\n };\n\n return retry.default(\n () => edgeSanityFetch<T>(options),\n { ...defaultRetryOptions, ...retryOptions }\n );\n}\n\n/**\n * Creates a cached Sanity fetcher\n * Note: Use cachedSanityFetch from cache.ts for full caching support\n */\nexport function createCachedSanityFetcher(\n dataset: string, \n _revalidate = 60,\n _tags?: string[]\n) {\n // Return basic fetcher - for caching use cachedSanityFetch from cache.ts\n return <T>(query: string, params?: QueryParams) => \n edgeSanityFetch<T>({\n dataset,\n query,\n ...(params !== undefined ? { params } : {}),\n useCdn: true\n });\n}\n\n/**\n * Creates an EventSource connection for real-time Sanity updates\n * Requires a server endpoint to handle SSE (see examples/vercel-sse.ts)\n */\nexport function createSanityEventSource(\n query: string,\n dataset = 'production',\n options?: {\n endpoint?: string;\n onMessage?: (data: unknown) => void;\n onError?: (error: Event) => void;\n }\n) {\n const endpoint = options?.endpoint || '/api/sanity-updates';\n const url = new URL(endpoint, window.location.origin);\n url.searchParams.set('query', query);\n url.searchParams.set('dataset', dataset);\n \n const eventSource = new EventSource(url.toString());\n \n if (options?.onMessage) {\n eventSource.onmessage = (event) => {\n try {\n const data = JSON.parse(event.data);\n if (options.onMessage) {\n options.onMessage(data);\n }\n } catch {\n // Failed to parse SSE data\n }\n };\n }\n \n if (options?.onError) {\n eventSource.onerror = options.onError;\n }\n \n return eventSource;\n}\n\n/**\n * Result type for batch fetching\n */\nexport type BatchResult<T extends Record<string, unknown>> = {\n [K in keyof T]: T[K] | null;\n};\n\n/**\n * Batch fetcher for multiple queries in a single request\n * Reduces API calls and improves performance\n */\nexport async function batchSanityFetch<T extends Record<string, unknown>>(\n queries: Record<string, { query: string; params?: QueryParams }>,\n dataset: string,\n options?: { useAuth?: boolean; useCdn?: boolean }\n): Promise<BatchResult<T>> {\n const results: Record<string, unknown> = {};\n \n // Use Promise.all for parallel fetching\n await Promise.all(\n Object.entries(queries).map(async ([key, { query, params }]) => {\n try {\n results[key] = await edgeSanityFetch({\n dataset,\n query,\n ...(params !== undefined ? { params } : {}),\n ...options\n });\n } catch {\n // Failed to fetch this query\n results[key] = null;\n }\n })\n );\n \n return results as BatchResult<T>;\n}"],"mappings":"ukBAAA,IAAAA,GAAA,GAAAC,GAAAD,GAAA,iBAAAE,EAAA,WAAAC,GAAA,wBAAAC,GAAA,YAAAC,GAAA,aAAAC,EAAA,oBAAAC,EAAA,mBAAAC