@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
text/typescript
/**
* @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,
});
}