@spoolcms/nextjs
Version:
The beautiful headless CMS for Next.js developers
277 lines (276 loc) • 11.4 kB
JavaScript
;
/**
* Spool Live Updates Hook - Convex Version
* This provides real-time content updates for customer Next.js apps
*/
'use client';
/**
* Spool Live Updates Hook - Convex Version
* This provides real-time content updates for customer Next.js apps
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.useSpoolLiveUpdates = useSpoolLiveUpdates;
exports.SpoolLiveUpdatesProvider = SpoolLiveUpdatesProvider;
const react_1 = __importStar(require("react"));
const react_2 = require("convex/react");
// Spool's Convex deployment URL (hardcoded - customers don't need to configure this)
const SPOOL_CONVEX_URL = 'https://sincere-hyena-934.convex.cloud';
// Global Convex client instance
let convexClient = null;
function getConvexClient() {
if (!convexClient) {
convexClient = new react_2.ConvexReactClient(SPOOL_CONVEX_URL);
// Ensure the client connects immediately for real-time updates
if (process.env.NODE_ENV === 'development') {
console.log('[DEV] 🔌 Initializing Convex client connection to:', SPOOL_CONVEX_URL);
}
}
return convexClient;
}
/**
* Hook for subscribing to Spool live updates
* This is what customers will use in their Next.js apps
*/
function useSpoolLiveUpdates(config = {}) {
const [isConnected, setIsConnected] = (0, react_1.useState)(false);
const [error, setError] = (0, react_1.useState)(null);
const onUpdateRef = (0, react_1.useRef)(config.onUpdate);
// Auto-detect credentials from environment
const apiKey = config.apiKey ||
(typeof window !== 'undefined' ?
window.__NEXT_DATA__?.props?.pageProps?.env?.NEXT_PUBLIC_SPOOL_API_KEY ||
process.env.NEXT_PUBLIC_SPOOL_API_KEY
: null);
const siteId = config.siteId ||
(typeof window !== 'undefined' ?
window.__NEXT_DATA__?.props?.pageProps?.env?.NEXT_PUBLIC_SPOOL_SITE_ID ||
process.env.NEXT_PUBLIC_SPOOL_SITE_ID
: null);
// Update the callback ref when it changes
(0, react_1.useEffect)(() => {
onUpdateRef.current = config.onUpdate;
}, [config.onUpdate]);
// Subscribe to live updates via Convex
// Note: Function name format for external deployment access
const updates = (0, react_2.useQuery)('liveUpdates:subscribe', config.enabled !== false && apiKey && siteId ? {
siteId,
apiKey,
limit: 10,
} : 'skip');
// Debug: Track every time useQuery result changes
(0, react_1.useEffect)(() => {
if (process.env.NODE_ENV === 'development') {
console.log('[DEV] 🔄 useQuery result changed. Latest update ID:', updates?.[0]?._id);
console.log('[DEV] 🔄 useQuery result changed. Total updates:', updates?.length);
console.log('[DEV] 🔄 useQuery result changed. Full updates array:', updates);
}
}, [updates]);
// Handle connection state
(0, react_1.useEffect)(() => {
if (updates !== undefined) {
setIsConnected(true);
setError(null);
if (process.env.NODE_ENV === 'development') {
console.log(`[DEV] ✅ Connected to Spool Realtime for site: ${siteId}`);
console.log(`[DEV] 📊 Updates received:`, updates);
console.log(`[DEV] 🔄 useQuery fired - latest update ID:`, updates[0]?._id);
console.log(`[DEV] 🔄 useQuery fired - latest timestamp:`, updates[0]?.timestamp);
}
}
}, [updates, siteId]);
// Track the latest update ID we've processed (per hook instance)
const [lastProcessedId, setLastProcessedId] = (0, react_1.useState)(null);
// Handle updates - process only new ones based on update ID
(0, react_1.useEffect)(() => {
if (!updates || updates.length === 0)
return;
// Get the most recent update (first in the array, since Convex returns newest first)
const latestUpdate = updates[0];
// Only process if this is a different update than we've seen before
if (latestUpdate && latestUpdate._id !== lastProcessedId) {
// Update the last processed ID
setLastProcessedId(latestUpdate._id);
if (process.env.NODE_ENV === 'development') {
console.log(`[DEV] 🔄 Live update: ${latestUpdate.collection}/${latestUpdate.slug || 'no-slug'}`);
console.log(`[DEV] 📅 Update timestamp: ${latestUpdate.timestamp}`);
console.log(`[DEV] 🎯 Update ID: ${latestUpdate._id}`);
console.log(`[DEV] 🚨 About to call user's onUpdate callback...`);
}
// Call user's callback with the latest update
if (onUpdateRef.current) {
onUpdateRef.current(latestUpdate);
if (process.env.NODE_ENV === 'development') {
console.log(`[DEV] ✅ User callback executed. If page doesn't update, it's a Next.js caching issue.`);
}
}
}
}, [updates, lastProcessedId]);
// Handle errors and debug info
(0, react_1.useEffect)(() => {
// Debug logging for connection issues
if (process.env.NODE_ENV === 'development') {
console.log(`[DEV] 🔍 Debug info:`, {
apiKey: apiKey ? `${apiKey.substring(0, 10)}...` : 'missing',
siteId: siteId || 'missing',
enabled: config.enabled !== false,
updatesState: updates === undefined ? 'undefined' : Array.isArray(updates) ? `array(${updates.length})` : typeof updates
});
}
// Convex will throw errors if authentication fails
// We can catch them here and provide user-friendly messages
if (updates === undefined && config.enabled !== false && apiKey && siteId) {
// Still loading, not an error yet
return;
}
}, [updates, config.enabled, apiKey, siteId]);
return {
isConnected,
error,
updates: updates || [],
latestUpdate: updates?.[0] || null,
};
}
/**
* Automatic revalidation logic
* This handles cache invalidation when content updates
*/
async function handleAutomaticRevalidation(update) {
if (typeof window !== 'undefined') {
// Client-side: trigger router refresh
if ('router' in window && typeof window.router?.refresh === 'function') {
window.router.refresh();
}
return;
}
// Server-side: trigger revalidation via HTTP
try {
const baseUrl = getAppBaseUrl();
const pathsToRevalidate = generateRevalidationPaths(update);
// Wait 2 seconds for API propagation
await new Promise(resolve => setTimeout(resolve, 2000));
const revalidationPromises = pathsToRevalidate.map(async (path) => {
try {
const response = await fetch(`${baseUrl}/api/revalidate?path=${encodeURIComponent(path)}`, {
method: 'POST',
headers: {
'Cache-Control': 'no-cache',
},
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
console.log(`[DEV] ✅ Revalidated: ${path}`);
}
else {
console.log(`[DEV] ❌ Revalidation failed for ${path}: ${response.status}`);
}
}
catch (err) {
console.log(`[DEV] ❌ Revalidation error for ${path}:`, err instanceof Error ? err.message : String(err));
}
});
await Promise.allSettled(revalidationPromises);
}
catch (error) {
console.error('[DEV] Error in automatic revalidation:', error);
}
}
/**
* Generate paths that need revalidation based on the update
*/
function generateRevalidationPaths(update) {
const paths = [];
// Always revalidate root
paths.push('/');
// Collection-specific paths
if (update.collection === 'blog') {
paths.push('/blog');
if (update.slug) {
paths.push(`/blog/${update.slug}`);
}
}
else {
paths.push(`/${update.collection}`);
if (update.slug) {
paths.push(`/${update.collection}/${update.slug}`);
}
}
// Common paths
paths.push('/sitemap.xml');
return paths;
}
/**
* Detect the current app URL
*/
function getAppBaseUrl() {
if (process.env.NODE_ENV === 'development') {
const port = process.env.PORT || process.env.NEXT_PUBLIC_PORT || '3000';
return `http://localhost:${port}`;
}
return process.env.NEXT_PUBLIC_SITE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000');
}
/**
* Provider component that automatically sets up Convex connection to Spool's infrastructure
* Customers just need to wrap their app with this - no additional configuration required!
*
* Usage:
* import { SpoolLiveUpdatesProvider } from '@spoolcms/nextjs';
*
* <SpoolLiveUpdatesProvider>
* <YourApp />
* </SpoolLiveUpdatesProvider>
*/
function SpoolLiveUpdatesProvider({ children }) {
const convexClient = getConvexClient();
// Try to import ConvexProvider synchronously first
let ConvexProvider = null;
try {
const convexReact = require('convex/react');
ConvexProvider = convexReact.ConvexProvider;
}
catch (error) {
// Convex not installed - provide fallback
console.warn('[SpoolLiveUpdates] Convex not installed. Live updates disabled. Install with: npm install convex');
return children;
}
if (!ConvexProvider) {
console.warn('[SpoolLiveUpdates] ConvexProvider not available. Live updates disabled.');
return children;
}
// Wrap with ConvexProvider using Spool's deployment
return react_1.default.createElement(ConvexProvider, { client: convexClient }, children);
}