import crypto from "node:crypto" import Sentry from "@sentry/nextjs" import jwt from "jsonwebtoken" import { type WarmupFunctionsKey } from "@/services/warmup/warmupKeys" import { configureSentry } from "../utils/initSentry" import { safeTry } from "../utils/safeTry" import type { Config, Context } from "@netlify/functions" const sentryLogger = Sentry.logger await configureSentry() export const config: Config = { method: "POST", } const langs = ["en", "sv", "no", "fi", "da", "de"] as const export const warmupKeys = [ ...langs.map((lang) => `countries_${lang}` as const), "hotelsByCountry", ...langs.map((lang) => `hotelData_${lang}` as const), ...langs.map((lang) => `autoComplete_${lang}` as const), ] satisfies WarmupFunctionsKey[] export default async function WarmupHandler( request: Request, context: Context ) { const [_, error] = await safeTry(validateRequest(request, context)) if (error) { if (error instanceof Error) { switch (error.message as ErrorCode) { case ErrorCodes.WARMUP_DISABLED: sentryLogger.warn("[warmup-background] Warmup is disabled") return case ErrorCodes.REQUEST_NOT_FOR_CURRENT_CONTEXT: // This is expected, this webhook will be called for all deployments // and we only want to warmup the ones that match our URL return } } sentryLogger.error(`[warmup-background] Warmup failed '${error}'`, { error: error.toString(), }) return } sentryLogger.info("[warmup-background] Request is valid, starting warmup") await performWarmup(context) sentryLogger.info("[warmup-background] Warmup completed") } async function validateRequest( request: Request, context: Context ): Promise { if (request.method !== "POST") { throw new Error(ErrorCodes.METHOD_NOT_ALLOWED) } const warmupEnabled = process.env.WARMUP_ENABLED === "true" || Netlify.env.get("WARMUP_ENABLED") === "true" if (!warmupEnabled) { throw new Error(ErrorCodes.WARMUP_DISABLED) } if (!request.body) { throw new Error(ErrorCodes.MISSING_BODY) } const body = await request.text() const deployment = JSON.parse(body) as DeploymentInfo if (!deployment) { throw new Error(ErrorCodes.UNABLE_TO_PARSE_DEPLOYMENT_INFO) } const isForCurrentContext = isRequestForCurrentContext({ currentUrl: context.url.origin, deployedUrl: deployment.deploy_ssl_url, }) if (!isForCurrentContext) { throw new Error(ErrorCodes.REQUEST_NOT_FOR_CURRENT_CONTEXT) } let signature: string try { const headerValue = request.headers.get("x-webhook-signature") if (!headerValue) { throw new Error(ErrorCodes.MISSING_SIGNATURE_HEADER) } signature = headerValue } catch (e) { sentryLogger.warn( sentryLogger.fmt`[warmup-background] Failed to parse signature ${e}`, { error: e instanceof Error ? e.toString() : String(e) } ) throw new Error(ErrorCodes.FAILED_TO_PARSE_SIGNATURE) } if (!signature) { throw new Error(ErrorCodes.MISSING_SIGNATURE) } const isValid = await validateSignature(signature, body) if (!isValid) { throw new Error(ErrorCodes.INVALID_SIGNATURE) } return true } export async function performWarmup(context: Context) { for (const key of warmupKeys) { sentryLogger.info( sentryLogger.fmt`[warmup-background] Warming up cache '${key}'` ) await callWarmup(key, context) // allow api to catch up await timeout(1000) } } async function callWarmup(key: (typeof warmupKeys)[number], context: Context) { const baseUrl = context.url.origin const url = new URL("/api/web/warmup", baseUrl) url.searchParams.set("key", key) const warmupToken = process.env.WARMUP_TOKEN || Netlify.env.get("WARMUP_TOKEN") try { const response = await fetch(url, { headers: { cache: "no-store", Authorization: `Bearer ${warmupToken}`, }, signal: AbortSignal.timeout(30_000), }) if (!response.ok) { sentryLogger.error( sentryLogger.fmt`[warmup-background] Warmup failed '${url.href}' with error: ${response.status}: ${response.statusText}` ) } } catch (error) { sentryLogger.error( sentryLogger.fmt`[warmup-background] Warmup failed '${url.href}' with error: ${error instanceof Error ? error.message : error}` ) } } async function validateSignature(token: string, buffer: string) { try { const secret = process.env.WARMUP_SIGNATURE_SECRET || Netlify.env.get("WARMUP_SIGNATURE_SECRET") if (!secret) { throw new Error("WARMUP_SIGNATURE_SECRET is not set") } const options: jwt.VerifyOptions = { issuer: "netlify", algorithms: ["HS256"], } const decoded = jwt.verify(token, secret, options) const hashedBody = crypto.createHash("sha256").update(buffer).digest("hex") if (!hasSha256(decoded)) { sentryLogger.error( "[warmup-background] Decoded jwt does not contain sha256, unable to verify signature" ) return false } return decoded.sha256 === hashedBody } catch (error) { sentryLogger.error( sentryLogger.fmt`[warmup-background] Failed to validate signature ${error}`, { error: error instanceof Error ? error.toString() : String(error) } ) return false } } function hasSha256(decoded: unknown): decoded is { sha256: string } { return ( typeof decoded === "object" && decoded !== null && "sha256" in decoded && typeof (decoded as { sha256: unknown }).sha256 === "string" ) } function isRequestForCurrentContext({ currentUrl, deployedUrl, }: { currentUrl: string deployedUrl: string }) { const isForProduction = currentUrl === "https://web-scandic-hotels.netlify.app" && deployedUrl === "https://release--web-scandic-hotels.netlify.app" return isForProduction || currentUrl === deployedUrl } type DeploymentInfo = { id: string site_id: string build_id: string state: string name: string url: string ssl_url: string admin_url: string deploy_url: string deploy_ssl_url: string created_at: string updated_at: string user_id: string error_message: string | null required: unknown[] required_functions: unknown[] commit_ref: string review_id: string | null branch: string commit_url: string skipped: unknown locked: unknown title: string commit_message: string | null review_url: string | null published_at: string | null context: string deploy_time: number available_functions: unknown[] screenshot_url: string | null committer: string skipped_log: unknown manual_deploy: boolean plugin_state: string lighthouse_plugin_scores: unknown links: { permalink: string alias: string branch: string | null } framework: string entry_path: string | null views_count: number | null function_schedules: { cron: string name: string }[] public_repo: boolean pending_review_reason: string | null lighthouse: unknown edge_functions_present: boolean expires_at: string | null blobs_region: string } const ErrorCodes = { DEPLOYMENT_NOT_FOUND: "DEPLOYMENT NOT FOUND", FAILED_TO_PARSE_SIGNATURE: "FAILED TO PARSE SIGNATURE", INVALID_DEPLOYMENT_STATE: "INVALID DEPLOYMENT STATE", INVALID_SIGNATURE: "INVALID SIGNATURE", METHOD_NOT_ALLOWED: "METHOD NOT ALLOWED", MISSING_BODY: "MISSING BODY", MISSING_SIGNATURE_HEADER: "MISSING SIGNATURE HEADER", MISSING_SIGNATURE: "MISSING SIGNATURE", UNABLE_TO_PARSE_DEPLOYMENT_INFO: "UNABLE TO PARSE DEPLOYMENT INFO", REQUEST_NOT_FOR_CURRENT_CONTEXT: "REQUEST NOT FOR CURRENT CONTEXT", WARMUP_DISABLED: "WARMUP IS DISABLED", } as const type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes] function timeout(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) }