UNPKG

@invisiblecities/sanity-edge-fetcher

Version:

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

419 lines (370 loc) 13.3 kB
/** * @file core.ts * @description Next.js-native, edge-compatible Sanity data fetcher with stega support * @author Invisible Cities Agency * @license MIT */ import { buildStegaConfig, processStegaResponse, type StegaConfig } from './stega'; // Helper to check if draft mode is enabled in Next.js async function isDraftModeEnabled(): Promise<boolean> { try { // Dynamic import to avoid build issues in non-Next.js environments const { draftMode } = await import('next/headers'); const draft = await draftMode(); return draft.isEnabled; } catch { // Not in Next.js or draft mode not available return false; } } // Detect if current request should enable stega/visual editing overlays // Uses Next.js headers/cookies if available, but degrades gracefully when not running in Next export async function detectStegaRequest(options?: { /** Feature flag cookie name set by Studio or a toggle endpoint (default: 'ic_stega') */ cookieName?: string; /** Optional custom header to allow one-shot enablement (default checks common names) */ headerName?: string; /** Studio URL or origin to validate Referer against; defaults to NEXT_PUBLIC_SANITY_STUDIO_URL */ studioUrl?: string; /** Force enable regardless of environment signals */ forceEnable?: boolean; /** Force disable regardless of environment signals */ forceDisable?: boolean; }): Promise<boolean> { if (options?.forceEnable) return true; if (options?.forceDisable) return false; // Defaults const cookieName = options?.cookieName ?? 'ic_stega'; const studioEnv = options?.studioUrl || process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || process.env.SANITY_STUDIO_URL; try { // Dynamic import to avoid hard Next.js dependency const mod = await import('next/headers'); const cookies = (mod as any).cookies?.bind(mod); const headers = (mod as any).headers?.bind(mod); const draft = (await ((mod as any).draftMode?.() ?? { isEnabled: false })); if (draft?.isEnabled) return true; // Check cookie flag const hasCookie = typeof cookies === 'function' ? Boolean(cookies().get(cookieName)?.value) : false; if (hasCookie) return true; // Check custom or common headers const hdrs = typeof headers === 'function' ? headers() : undefined; const headerName = options?.headerName; const headerCandidates = [headerName, 'x-ic-stega', 'x-sanity-present', 'x-sanity-preview'].filter(Boolean) as string[]; if (hdrs) { for (const name of headerCandidates) { const v = hdrs.get(name); if (v && v !== '0' && v.toLowerCase() !== 'false') return true; } } // Referer origin check against Studio origin if (hdrs && studioEnv) { const ref = hdrs.get('referer'); if (ref) { try { const refererOrigin = new URL(ref).origin; const studioOrigin = new URL(studioEnv).origin; if (refererOrigin === studioOrigin) return true; } catch { // ignore URL parse errors } } } } catch { // Not in Next.js environment; fall through } // Fallback: enable in development if explicitly configured via env if (process.env.NEXT_PUBLIC_ENABLE_STEGA === '1') return true; return false; } // Get config from environment variables const getProjectId = () => { const id = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID; if (!id) { throw new Error('NEXT_PUBLIC_SANITY_PROJECT_ID environment variable is required'); } return id; }; const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2025-02-10'; // Get the viewer token - check multiple possible env vars const getViewerToken = () => { return process.env.SANITY_VIEWER_TOKEN || process.env.SANITY_API_READ_TOKEN || process.env.NEXT_PUBLIC_SANITY_VIEWER_TOKEN; }; export type QueryParams = Record<string, string | number | boolean | null | undefined | Array<string | number | boolean>>; export interface EdgeSanityFetchOptions { /** Sanity dataset to query (e.g., 'production', 'staging') */ dataset: string; /** GROQ query string */ query: string; /** Optional query parameters for GROQ placeholders */ params?: QueryParams; /** Whether to use Sanity's CDN (faster but no auth) */ useCdn?: boolean; /** Whether to include auth token for draft preview access */ useAuth?: boolean; /** Stega configuration for visual editing */ stega?: Partial<StegaConfig>; } /** * Simple rate limiter to prevent 429 errors * @internal */ class EdgeRateLimiter { private lastRequest = 0; private readonly minInterval = 100; // 10 req/sec max async throttle(): Promise<void> { const now = Date.now(); const timeSinceLastRequest = now - this.lastRequest; if (timeSinceLastRequest < this.minInterval) { const delay = this.minInterval - timeSinceLastRequest; await new Promise(resolve => setTimeout(resolve, delay)); } this.lastRequest = Date.now(); } } const rateLimiter = new EdgeRateLimiter(); /** * Fetches data from Sanity using native fetch API * Compatible with Edge Runtime and static generation */ export async function edgeSanityFetch<T>({ dataset, query, params = {}, useCdn = false, useAuth = false, stega }: EdgeSanityFetchOptions): Promise<T> { // Apply rate limiting await rateLimiter.throttle(); // Build the query URL const projectId = getProjectId(); // Determine if we need source maps for stega (before choosing base URL) const isDraftMode = useAuth; const stegaConfig = buildStegaConfig(isDraftMode, stega); // When stega is enabled, force non-CDN API to ensure resultSourceMap is returned const useCdnEffective = stegaConfig.enabled ? false : useCdn; const baseUrl = useCdnEffective ? `https://${projectId}.apicdn.sanity.io` : `https://${projectId}.api.sanity.io`; const url = new URL(`${baseUrl}/v${apiVersion}/data/query/${dataset}`); url.searchParams.set('query', query); if (useAuth) { // Use 'previewDrafts' perspective to see draft documents merged with published url.searchParams.set('perspective', 'previewDrafts'); } // Request source maps when stega is enabled if (stegaConfig.enabled) { url.searchParams.set('resultSourceMap', 'true'); } // Add parameters Object.entries(params).forEach(([key, value]) => { url.searchParams.set(`$${key}`, JSON.stringify(value)); }); // Build headers const headers: Record<string, string> = { 'Accept': 'application/json', }; // Use env var for auth to maintain static generation compatibility if (useAuth) { const envToken = getViewerToken(); if (envToken) { headers['Authorization'] = `Bearer ${envToken}`; } } const response = await fetch(url.toString(), { method: 'GET', headers, }); if (!response.ok) { await response.text(); // Consume the body to prevent memory leak throw new Error(`Sanity fetch failed: ${response.status} ${response.statusText}`); } const data = await response.json(); // Process response with stega support (reuse stegaConfig from above) return processStegaResponse(data, isDraftMode, stegaConfig); } /** * Factory function to create a typed Sanity fetcher for a given dataset */ export function createEdgeSanityFetcher(dataset: string, useAuth = false, stega?: Partial<StegaConfig>) { return <T>(query: string, params?: QueryParams) => { const options: EdgeSanityFetchOptions = { dataset, query, useAuth, ...(stega !== undefined ? { stega } : {}), ...(params !== undefined ? { params } : {}), }; return edgeSanityFetch<T>(options); }; } /** * Next.js-aware Sanity fetcher that automatically handles draft mode * This is the primary fetcher for Next.js applications * * @example * const data = await sanityFetch('*[_type == "post"][0]'); */ export async function sanityFetch<T = unknown>( query: string, params?: QueryParams, options?: { dataset?: string; /** Override automatic draft mode detection */ forceAuth?: boolean; /** Stega configuration for visual editing */ stega?: Partial<StegaConfig>; } ): Promise<T> { const dataset = options?.dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production'; const useAuth = options?.forceAuth ?? await isDraftModeEnabled(); return edgeSanityFetch<T>({ dataset, query, ...(params !== undefined ? { params } : {}), useCdn: !useAuth, // Use CDN when not authenticated useAuth, stega: options?.stega || { enabled: useAuth }, // Enable stega in draft mode by default }); } /** * Presentation-aware hybrid fetcher * Automatically enables authenticated fetch + stega overlays when a Studio/Presentation * signal is detected (draftMode cookie, feature flag cookie, referer from Studio, or header). * Otherwise uses fast CDN fetch with no stega. */ export async function sanityFetchHybrid<T = unknown>( query: string, params?: QueryParams, options?: { dataset?: string; /** Optional stega options to merge with defaults */ stega?: Partial<StegaConfig>; /** Detection overrides */ cookieName?: string; headerName?: string; studioUrl?: string; forceEnableStega?: boolean; forceDisableStega?: boolean; } ): Promise<T> { const dataset = options?.dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production'; const enableStega = await detectStegaRequest({ ...(options?.cookieName !== undefined ? { cookieName: options.cookieName } : {}), ...(options?.headerName !== undefined ? { headerName: options.headerName } : {}), ...(options?.studioUrl !== undefined ? { studioUrl: options.studioUrl } : {}), ...(options?.forceEnableStega !== undefined ? { forceEnable: options.forceEnableStega } : {}), ...(options?.forceDisableStega !== undefined ? { forceDisable: options.forceDisableStega } : {}), }); return edgeSanityFetch<T>({ dataset, query, ...(params !== undefined ? { params } : {}), useCdn: !enableStega, useAuth: enableStega, stega: { enabled: enableStega, ...(options?.stega || {}) }, }); } /** * Sanity fetcher with automatic draft fallback * Tries to fetch published content first, falls back to drafts if empty * Perfect for singleton documents that might only exist as drafts * * @example * const page = await sanityFetchWithFallback('*[_type == "page" && slug.current == $slug][0]', { slug }); */ export async function sanityFetchWithFallback<T = unknown>( query: string, params?: QueryParams, options?: { dataset?: string; /** Log when falling back to drafts */ logFallback?: boolean; /** Stega configuration for visual editing */ stega?: Partial<StegaConfig>; } ): Promise<T> { const dataset = options?.dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production'; const isNextDraftMode = await isDraftModeEnabled(); // If already in draft mode, just use authenticated fetch if (isNextDraftMode) { return edgeSanityFetch<T>({ dataset, query, ...(params !== undefined ? { params } : {}), useCdn: false, useAuth: true, stega: options?.stega || { enabled: true }, }); } // Try published content first const publishedResult = await edgeSanityFetch<T>({ dataset, query, ...(params !== undefined ? { params } : {}), useCdn: true, useAuth: false, }); // If we got content, return it if (publishedResult) { return publishedResult; } // No published content, try drafts if (options?.logFallback !== false && process.env.NODE_ENV !== 'production') { console.warn('[sanityFetchWithFallback] No published content found, checking for drafts...'); } const draftResult = await edgeSanityFetch<T>({ dataset, query, ...(params !== undefined ? { params } : {}), useCdn: false, useAuth: true, stega: options?.stega || { enabled: true }, }); if (draftResult && options?.logFallback !== false && process.env.NODE_ENV !== 'production') { console.warn('[sanityFetchWithFallback] Draft content found and returned'); } return draftResult; } /** * Static content fetcher - always uses CDN, never authenticates * Use for global settings and content that rarely changes * * @example * const settings = await sanityFetchStatic('*[_type == "siteSettings"][0]'); */ export async function sanityFetchStatic<T = unknown>( query: string, params?: QueryParams, dataset?: string ): Promise<T> { return edgeSanityFetch<T>({ dataset: dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production', query, ...(params !== undefined ? { params } : {}), useCdn: true, useAuth: false, }); } /** * Authenticated fetcher - always uses authentication * Use when you need to ensure draft content is visible * * @example * const drafts = await sanityFetchAuthenticated('*[_type == "post" && _id in path("drafts.**")]'); */ export async function sanityFetchAuthenticated<T = unknown>( query: string, params?: QueryParams, dataset?: string ): Promise<T> { return edgeSanityFetch<T>({ dataset: dataset || process.env.NEXT_PUBLIC_SANITY_DATASET || 'production', query, ...(params !== undefined ? { params } : {}), useCdn: false, useAuth: true, }); }