feat: upgrade sentry and use metrics * feat: upgrade sentry and use metrics * remove ununsed deps * rename span * . Approved-by: Linus Flood
295 lines
7.8 KiB
TypeScript
295 lines
7.8 KiB
TypeScript
import crypto from "node:crypto"
|
|
|
|
import * as 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"
|
|
|
|
await configureSentry()
|
|
|
|
export const config: Config = {
|
|
method: "POST",
|
|
}
|
|
const warmupLogger = {
|
|
info: (message: string, ...args: unknown[]) => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[WARMUP] ${message}`, ...args)
|
|
Sentry.logger.info(`[WARMUP] ${message}`, { ...args })
|
|
},
|
|
warn: (message: string, ...args: unknown[]) => {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`[WARMUP] ${message}`, ...args)
|
|
Sentry.logger.warn(`[WARMUP] ${message}`, { ...args })
|
|
},
|
|
error: (message: string, ...args: unknown[]) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[WARMUP] ${message}`, ...args)
|
|
Sentry.logger.error(`[WARMUP] ${message}`, { ...args })
|
|
},
|
|
}
|
|
|
|
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:
|
|
warmupLogger.warn("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
|
|
}
|
|
}
|
|
|
|
warmupLogger.error("Warmup failed", error)
|
|
return
|
|
}
|
|
|
|
warmupLogger.info("Request is valid, starting warmup")
|
|
await performWarmup(context)
|
|
warmupLogger.info("Warmup completed")
|
|
}
|
|
|
|
async function validateRequest(
|
|
request: Request,
|
|
context: Context
|
|
): Promise<true> {
|
|
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) {
|
|
warmupLogger.warn("Failed to parse signature", 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) {
|
|
warmupLogger.info("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) {
|
|
warmupLogger.error(
|
|
`Warmup failed '${url.href}' with error: ${response.status}: ${response.statusText}`
|
|
)
|
|
}
|
|
} catch (error) {
|
|
warmupLogger.error(
|
|
`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)) {
|
|
warmupLogger.error(
|
|
"Decoded jwt does not contain sha256, unable to verify signature"
|
|
)
|
|
return false
|
|
}
|
|
|
|
return decoded.sha256 === hashedBody
|
|
} catch (error) {
|
|
warmupLogger.error("Failed to validate signature", 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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|