@spoolcms/nextjs
Version:
The beautiful headless CMS for Next.js developers
222 lines (221 loc) • 8.23 kB
JavaScript
/**
* Webhook utilities for Spool CMS
* Use these functions in your Next.js webhook endpoints to verify and handle Spool webhooks
*
* NOTE: For live updates, use the new useSpoolLiveUpdates hook instead of webhooks.
* Webhooks are still useful for production deployments and server-side processing.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.verifySpoolWebhook = verifySpoolWebhook;
exports.parseSpoolWebhook = parseSpoolWebhook;
exports.getSpoolWebhookHeaders = getSpoolWebhookHeaders;
exports.createSpoolWebhookHandler = createSpoolWebhookHandler;
const crypto_1 = __importDefault(require("crypto"));
/**
* Verify webhook signature from Spool CMS
* Use this in your webhook endpoint to ensure the request is from Spool
*
* @param payload - The raw request body as a string
* @param signature - The X-Spool-Signature-256 header value
* @param secret - Your webhook secret from Spool admin settings
* @returns true if signature is valid, false otherwise
*
* @example
* ```typescript
* import { verifySpoolWebhook } from '@spoolcms/nextjs';
*
* export async function POST(request: Request) {
* const payload = await request.text();
* const signature = request.headers.get('x-spool-signature-256');
* const secret = process.env.SPOOL_WEBHOOK_SECRET;
*
* if (secret && signature && !verifySpoolWebhook(payload, signature, secret)) {
* return new Response('Unauthorized', { status: 401 });
* }
*
* // Process webhook...
* }
* ```
*/
function verifySpoolWebhook(payload, signature, secret) {
if (!secret || !signature) {
return false;
}
try {
const expectedSignature = `sha256=${crypto_1.default
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex')}`;
return crypto_1.default.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
}
catch (error) {
console.error('Error verifying Spool webhook signature:', error);
return false;
}
}
/**
* Parse and validate Spool webhook payload
*
* @param payload - The raw request body as a string
* @returns Parsed webhook payload or null if invalid
*
* @example
* ```typescript
* import { parseSpoolWebhook } from '@spoolcms/nextjs';
*
* export async function POST(request: Request) {
* const payload = await request.text();
* const data = parseSpoolWebhook(payload);
*
* if (!data) {
* return new Response('Invalid payload', { status: 400 });
* }
*
* console.log(`Received ${data.event} for ${data.collection}/${data.slug}`);
* }
* ```
*/
function parseSpoolWebhook(payload) {
try {
const data = JSON.parse(payload);
// Validate required fields
if (!data.event || !data.site_id || !data.collection || !data.item_id || !data.timestamp) {
return null;
}
// Validate event type
const validEvents = ['content.created', 'content.updated', 'content.published', 'content.deleted'];
if (!validEvents.includes(data.event)) {
return null;
}
return data;
}
catch (error) {
console.error('Error parsing Spool webhook payload:', error);
return null;
}
}
/**
* Get webhook headers from a Request object
* Extracts Spool-specific headers for logging and debugging
*
* @param request - The Next.js Request object
* @returns Object with Spool webhook headers
*
* @example
* ```typescript
* import { getSpoolWebhookHeaders } from '@spoolcms/nextjs';
*
* export async function POST(request: Request) {
* const headers = getSpoolWebhookHeaders(request);
* console.log(`Processing delivery ${headers.deliveryId} for event ${headers.event}`);
* }
* ```
*/
function getSpoolWebhookHeaders(request) {
return {
signature: request.headers.get('x-spool-signature-256') || undefined,
deliveryId: request.headers.get('x-spool-delivery') || undefined,
event: request.headers.get('x-spool-event') || undefined,
userAgent: request.headers.get('user-agent') || undefined,
};
}
/**
* Create a simple webhook handler with verification and parsing
*
* NOTE: For live updates, use the new useSpoolLiveUpdates hook instead.
* This webhook handler is for production deployments and server-side processing only.
*
* @param options - Configuration options
* @returns Webhook handler function
*
* @example
* ```typescript
* import { createSpoolWebhookHandler } from '@spoolcms/nextjs';
* import { revalidatePath } from 'next/cache';
*
* const handleWebhook = createSpoolWebhookHandler({
* secret: process.env.SPOOL_WEBHOOK_SECRET,
* onWebhook: async (data, headers) => {
* console.log(`Processing ${data.event} for ${data.collection}/${data.slug}`);
*
* // Revalidate paths based on collection
* if (data.collection === 'blog') {
* revalidatePath('/blog');
* if (data.slug) revalidatePath(`/blog/${data.slug}`);
* }
*
* revalidatePath('/');
* }
* });
*
* export const POST = handleWebhook;
* ```
*/
function createSpoolWebhookHandler(options) {
return async function webhookHandler(request) {
const startTime = Date.now();
let headers = {};
try {
const payload = await request.text();
headers = getSpoolWebhookHeaders(request);
// Verify signature if secret is provided
if (options.secret && headers.signature) {
const isValid = verifySpoolWebhook(payload, headers.signature, options.secret);
if (!isValid) {
console.error(`[${headers.deliveryId || 'unknown'}] Invalid webhook signature`);
return new Response('Unauthorized', { status: 401 });
}
}
// Parse payload
const data = parseSpoolWebhook(payload);
if (!data) {
console.error(`[${headers.deliveryId || 'unknown'}] Invalid webhook payload:`, payload.substring(0, 200));
return new Response('Invalid payload', { status: 400 });
}
console.log(`[${headers.deliveryId || 'unknown'}] Processing webhook: ${data.event} for ${data.collection}${data.slug ? `/${data.slug}` : ''}`);
// Call user handler with error boundary (if provided)
if (options.onWebhook) {
try {
await options.onWebhook(data, headers);
}
catch (handlerError) {
console.error(`[${headers.deliveryId || 'unknown'}] Error in user webhook handler:`, handlerError);
throw handlerError; // Re-throw to be caught by outer try-catch
}
}
const duration = Date.now() - startTime;
console.log(`[${headers.deliveryId || 'unknown'}] Webhook processed successfully in ${duration}ms`);
return new Response('OK', {
headers: {
'X-Spool-Processed': 'true',
'X-Processing-Time': `${duration}ms`
}
});
}
catch (error) {
const duration = Date.now() - startTime;
console.error(`[${headers.deliveryId || 'unknown'}] Webhook error after ${duration}ms:`, error);
if (options.onError) {
try {
return await options.onError(error, request);
}
catch (errorHandlerError) {
console.error('Error in custom error handler:', errorHandlerError);
// Fall through to default error response
}
}
return new Response('Error processing webhook', {
status: 500,
headers: {
'X-Spool-Error': 'true',
'X-Processing-Time': `${duration}ms`,
'X-Error-Message': error instanceof Error ? error.message : 'Unknown error'
}
});
}
};
}
;