From c1505ce50ee1baccdab86e863e4ccc08881db070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20J=C3=A4derberg?= Date: Tue, 29 Apr 2025 06:18:14 +0000 Subject: [PATCH] Merged in feature/warmup (pull request #1887) * unified warmup function Approved-by: Linus Flood --- apps/scandic-web/app/api/web/warmup/route.ts | 139 ++++++++++ .../EnterDetails/Summary/UI/index.tsx | 46 ++-- .../Steps/ConfirmationStep/index.tsx | 4 +- .../ReferenceCard/Modal/Button/index.tsx | 7 +- .../MyStay/TrackGuarantee.tsx | 25 +- .../PriceDetailsTable/Tbody/index.tsx | 2 +- .../RoomsList/RoomListItem/Rates/Campaign.tsx | 90 +++---- .../RoomListItem/Rates/Redemptions.tsx | 6 +- apps/scandic-web/env/server.ts | 2 + .../functions/hoteldata-da-background.mts | 12 - .../functions/hoteldata-de-background.mts | 12 - .../functions/hoteldata-en-background.mts | 12 - .../functions/hoteldata-fi-background.mts | 12 - .../functions/hoteldata-no-background.mts | 12 - .../functions/hoteldata-sv-background.mts | 12 - .../netlify/functions/warmup-background.mts | 249 ++++++++++++++++++ .../functions/warmup-scheduled-background.mts | 14 + apps/scandic-web/package.json | 2 + .../server/routers/hotels/query.ts | 10 +- .../server/routers/hotels/utils.ts | 7 +- apps/scandic-web/services/dataCache/Cache.ts | 6 +- .../dataCache/DistributedCache/cacheOrGet.ts | 13 +- .../MemoryCache/InMemoryCache/cacheOrGet.ts | 19 +- .../MemoryCache/UnstableCache/cacheOrGet.ts | 12 +- .../services/dataCache/cacheOrGetOptions.ts | 27 ++ apps/scandic-web/services/dataCache/index.ts | 3 +- apps/scandic-web/services/warmup/index.ts | 50 ++++ .../services/warmup/warmupCountries.ts | 28 ++ .../services/warmup/warmupHotelData.ts | 31 +++ .../warmup/warmupHotelIdsByCountry.ts | 60 +++++ .../scandic-web/services/warmup/warmupKeys.ts | 20 ++ apps/scandic-web/utils/logger/index.ts | 16 ++ yarn.lock | 111 +++++++- 33 files changed, 886 insertions(+), 185 deletions(-) create mode 100644 apps/scandic-web/app/api/web/warmup/route.ts delete mode 100644 apps/scandic-web/netlify/functions/hoteldata-da-background.mts delete mode 100644 apps/scandic-web/netlify/functions/hoteldata-de-background.mts delete mode 100644 apps/scandic-web/netlify/functions/hoteldata-en-background.mts delete mode 100644 apps/scandic-web/netlify/functions/hoteldata-fi-background.mts delete mode 100644 apps/scandic-web/netlify/functions/hoteldata-no-background.mts delete mode 100644 apps/scandic-web/netlify/functions/hoteldata-sv-background.mts create mode 100644 apps/scandic-web/netlify/functions/warmup-background.mts create mode 100644 apps/scandic-web/netlify/functions/warmup-scheduled-background.mts create mode 100644 apps/scandic-web/services/dataCache/cacheOrGetOptions.ts create mode 100644 apps/scandic-web/services/warmup/index.ts create mode 100644 apps/scandic-web/services/warmup/warmupCountries.ts create mode 100644 apps/scandic-web/services/warmup/warmupHotelData.ts create mode 100644 apps/scandic-web/services/warmup/warmupHotelIdsByCountry.ts create mode 100644 apps/scandic-web/services/warmup/warmupKeys.ts create mode 100644 apps/scandic-web/utils/logger/index.ts diff --git a/apps/scandic-web/app/api/web/warmup/route.ts b/apps/scandic-web/app/api/web/warmup/route.ts new file mode 100644 index 000000000..acb0bf79c --- /dev/null +++ b/apps/scandic-web/app/api/web/warmup/route.ts @@ -0,0 +1,139 @@ +import { type NextRequest, NextResponse } from "next/server" + +import { env } from "@/env/server" + +import { warmup } from "@/services/warmup" +import { isWarmupKey } from "@/services/warmup/warmupKeys" +import { createLogger } from "@/utils/logger" + +export const dynamic = "force-dynamic" + +const logger = createLogger("Warmup") + +export async function GET(req: NextRequest) { + const url = new URL(req.url) + const key = url.searchParams.get("key") + + if (!isAuthroized(req)) { + return unauthorizedResponse() + } + + const executionStart = performance.now() + + if (!isWarmupKey(key)) { + return invalidKeyResponse(key) + } + + logger.debug("Warming up:", key) + const warmupResult = await warmup(key) + const executionTime = performance.now() - executionStart + + switch (warmupResult.status) { + case "completed": + return warmupCompletedResponse(warmupResult, key, executionTime) + case "error": + return warmupErrorResponse(warmupResult, key, executionTime) + case "skipped": + return warmupSkippedResponse(warmupResult, key, executionTime) + default: + const status = (warmupResult as unknown as { status: string }).status + throw new Error(`Unknown warmup status '${status}'`) + } +} + +function isAuthroized(req: NextRequest) { + const authHeader = req.headers.get("Authorization") + if (!authHeader || !env.WARMUP_TOKEN) { + return false + } + + const token = authHeader.split(" ")[1] + if (!token) { + return false + } + + return token === env.WARMUP_TOKEN +} + +function warmupCompletedResponse( + warmupResult: Awaited>, + key: string, + executionTime: number +) { + logger.debug(`Warmup completed: ${key} in ${executionTime.toFixed(2)}ms`) + return NextResponse.json( + { + ...warmupResult, + key, + executionTime: `${executionTime.toFixed(2)}ms`, + }, + { status: 200 } + ) +} + +function warmupSkippedResponse( + warmupResult: Awaited>, + key: string, + executionTime: number +) { + logger.debug("Warmup skipped:", key) + + return NextResponse.json( + { + key, + status: warmupResult.status, + executionTime: `${executionTime.toFixed(2)}ms`, + }, + { status: 200 } + ) +} + +function warmupErrorResponse( + warmupResult: Awaited>, + key: string, + executionTime: number +) { + if (warmupResult.status !== "error") { + throw new Error("Warmup result is not an error") + } + + logger.error("Warmup error", { + key, + error: warmupResult.error.message, + }) + + return NextResponse.json( + { + key, + status: warmupResult.status, + error: warmupResult.error.message, + executionTime: `${executionTime.toFixed(2)}ms`, + }, + { status: 500 } + ) +} + +function unauthorizedResponse() { + return NextResponse.json( + { + error: "Unauthorized access", + }, + { status: 401 } + ) +} + +function invalidKeyResponse(key: string | null) { + if (isWarmupKey(key)) { + throw new Error("Invalid invocation with valid cache key") + } + + logger.error(`Invalid key ${key}`) + + return NextResponse.json( + { + key, + error: `Invalid warmup key: '${key}'`, + }, + { status: 400 } + ) +} diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index 216194384..003b7a17a 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -210,17 +210,17 @@ export default function SummaryUI({ {showMemberPrice ? formatPrice( - intl, - memberPrice.amount, - memberPrice.currency - ) + intl, + memberPrice.amount, + memberPrice.currency + ) : formatPrice( - intl, - room.roomPrice.perStay.local.price, - room.roomPrice.perStay.local.currency, - room.roomPrice.perStay.local.additionalPrice, - room.roomPrice.perStay.local.additionalPriceCurrency - )} + intl, + room.roomPrice.perStay.local.price, + room.roomPrice.perStay.local.currency, + room.roomPrice.perStay.local.additionalPrice, + room.roomPrice.perStay.local.additionalPriceCurrency + )} @@ -272,22 +272,22 @@ export default function SummaryUI({ {room.roomFeatures ? room.roomFeatures.map((feature) => ( -
-
+
+
+ + {feature.description} + +
+ - {feature.description} + {formatPrice( + intl, + feature.localPrice.price, + feature.localPrice.currency + )}
- - - {formatPrice( - intl, - feature.localPrice.price, - feature.localPrice.currency - )} - -
- )) + )) : null} {room.bedType ? (
diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/AddAncillaryFlowModal/Steps/ConfirmationStep/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/AddAncillaryFlowModal/Steps/ConfirmationStep/index.tsx index e52f18e24..7dad19129 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/AddAncillaryFlowModal/Steps/ConfirmationStep/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/AddAncillaryFlowModal/Steps/ConfirmationStep/index.tsx @@ -144,8 +144,8 @@ export default function ConfirmationStep({ label={ savedCreditCards?.length ? intl.formatMessage({ - defaultMessage: "OTHER", - }) + defaultMessage: "OTHER", + }) : undefined } > diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Modal/Button/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Modal/Button/index.tsx index 08d04a02f..2febd8701 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Modal/Button/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/Modal/Button/index.tsx @@ -2,14 +2,13 @@ import { Button as ButtonRAC } from "react-aria-components" import { - MaterialIcon -,type - MaterialIconProps} from "@scandic-hotels/design-system/Icons/MaterialIcon" + MaterialIcon, + type MaterialIconProps, +} from "@scandic-hotels/design-system/Icons/MaterialIcon" import { Typography } from "@scandic-hotels/design-system/Typography" import styles from "./button.module.css" - interface ButtonProps extends React.PropsWithChildren { icon: MaterialIconProps["icon"] isDisabled?: boolean diff --git a/apps/scandic-web/components/HotelReservation/MyStay/TrackGuarantee.tsx b/apps/scandic-web/components/HotelReservation/MyStay/TrackGuarantee.tsx index d64afa4bd..77896769d 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/TrackGuarantee.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/TrackGuarantee.tsx @@ -9,14 +9,13 @@ import { clearGlaSessionStorage, readGlaFromSessionStorage, } from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/helpers" -import LoadingSpinner from "@/components/LoadingSpinner" -import { trackEvent } from "@/utils/tracking/base" -import { buildAncillaries } from "@/utils/tracking/myStay" - import { buildAncillaryPackages, getAncillarySessionData, } from "@/components/HotelReservation/MyStay/utils/ancillaries" +import LoadingSpinner from "@/components/LoadingSpinner" +import { trackEvent } from "@/utils/tracking/base" +import { buildAncillaries } from "@/utils/tracking/myStay" interface TrackGuaranteeProps { status: string @@ -93,21 +92,21 @@ export default function TrackGuarantee({ case PaymentCallbackStatusEnum.Cancel: isAncillaryFlow ? trackAncillaryPaymentEvent( - "GuaranteeCancelAncillary", - "glacardsavecancelled" - ) + "GuaranteeCancelAncillary", + "glacardsavecancelled" + ) : trackGuaranteePaymentEvent( - "glaCardSaveCancelled", - "glacardsavecancelled" - ) + "glaCardSaveCancelled", + "glacardsavecancelled" + ) break case PaymentCallbackStatusEnum.Error: isAncillaryFlow ? trackAncillaryPaymentEvent( - "GuaranteeFailAncillary", - "glacardsavefailed" - ) + "GuaranteeFailAncillary", + "glacardsavefailed" + ) : trackGuaranteePaymentEvent("glaCardSaveFailed", "glacardsavefailed") break } diff --git a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Tbody/index.tsx b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Tbody/index.tsx index f55371598..68a2e1be7 100644 --- a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Tbody/index.tsx +++ b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Tbody/index.tsx @@ -1,4 +1,4 @@ -import { type TbodyProps,tbodyVariants } from "./variants" +import { type TbodyProps, tbodyVariants } from "./variants" export default function Tbody({ border, children }: TbodyProps) { const classNames = tbodyVariants({ border }) diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Campaign.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Campaign.tsx index d33c262f3..ebeec7541 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Campaign.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Campaign.tsx @@ -29,12 +29,8 @@ export default function Campaign({ roomTypeCode, }: CampaignProps) { const intl = useIntl() - const { - roomNr, - selectedFilter, - selectedPackages, - selectedRate, - } = useRoomContext() + const { roomNr, selectedFilter, selectedPackages, selectedRate } = + useRoomContext() const rateTitles = useRateTitles() const isCampaignRate = campaign.some( @@ -78,21 +74,21 @@ export default function Campaign({ const rateTermDetails = product.rateDefinitionMember ? [ - { - title: product.rateDefinition.title, - terms: product.rateDefinition.generalTerms, - }, - { - title: product.rateDefinitionMember.title, - terms: product.rateDefinition.generalTerms, - }, - ] + { + title: product.rateDefinition.title, + terms: product.rateDefinition.generalTerms, + }, + { + title: product.rateDefinitionMember.title, + terms: product.rateDefinition.generalTerms, + }, + ] : [ - { - title: product.rateDefinition.title, - terms: product.rateDefinition.generalTerms, - }, - ] + { + title: product.rateDefinition.title, + terms: product.rateDefinition.generalTerms, + }, + ] const isSelected = isSelectedPriceProduct( product, @@ -127,12 +123,12 @@ export default function Campaign({ const pricePerNightMember = product.member ? calculatePricePerNightPriceProduct( - product.member.localPrice.pricePerNight, - product.member.requestedPrice?.pricePerNight, - nights, - pkgsSum.price, - pkgsSumRequested.price - ) + product.member.localPrice.pricePerNight, + product.member.requestedPrice?.pricePerNight, + nights, + pkgsSum.price, + pkgsSumRequested.price + ) : undefined let approximateRatePrice = undefined @@ -148,12 +144,12 @@ export default function Campaign({ const approximateRate = approximateRatePrice && product.public.requestedPrice ? { - label: intl.formatMessage({ - defaultMessage: "Approx.", - }), - price: approximateRatePrice, - unit: product.public.requestedPrice.currency, - } + label: intl.formatMessage({ + defaultMessage: "Approx.", + }), + price: approximateRatePrice, + unit: product.public.requestedPrice.currency, + } : undefined return ( @@ -167,12 +163,12 @@ export default function Campaign({ memberRate={ pricePerNightMember ? { - label: intl.formatMessage({ - defaultMessage: "Member price", - }), - price: pricePerNightMember.totalPrice, - unit: `${product.member!.localPrice.currency}/${night}`, - } + label: intl.formatMessage({ + defaultMessage: "Member price", + }), + price: pricePerNightMember.totalPrice, + unit: `${product.member!.localPrice.currency}/${night}`, + } : undefined } name={`rateCode-${roomNr}-${product.public.rateCode}`} @@ -186,15 +182,15 @@ export default function Campaign({ omnibusRate={ product.public.localPrice.omnibusPricePerNight ? { - label: intl - .formatMessage({ - defaultMessage: "Lowest price (last 30 days)", - }) - .toUpperCase(), - price: - product.public.localPrice.omnibusPricePerNight.toString(), - unit: product.public.localPrice.currency, - } + label: intl + .formatMessage({ + defaultMessage: "Lowest price (last 30 days)", + }) + .toUpperCase(), + price: + product.public.localPrice.omnibusPricePerNight.toString(), + unit: product.public.localPrice.currency, + } : undefined } rateTermDetails={rateTermDetails} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Redemptions.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Redemptions.tsx index c828437c0..abad0e890 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Redemptions.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Redemptions.tsx @@ -79,9 +79,9 @@ export default function Redemptions({ additionalPrice: additionalPrice && additionalPriceCurrency ? { - currency: additionalPriceCurrency, - price: additionalPrice.toString(), - } + currency: additionalPriceCurrency, + price: additionalPrice.toString(), + } : undefined, currency: "PTS", isDisabled: !r.redemption.hasEnoughPoints, diff --git a/apps/scandic-web/env/server.ts b/apps/scandic-web/env/server.ts index 1991ed50f..b34ab606a 100644 --- a/apps/scandic-web/env/server.ts +++ b/apps/scandic-web/env/server.ts @@ -199,6 +199,7 @@ export const env = createEnv({ // transform to boolean .transform((s) => s === "true") .default("true"), + WARMUP_TOKEN: z.string().optional(), }, emptyStringAsUndefined: true, runtimeEnv: { @@ -293,6 +294,7 @@ export const env = createEnv({ BRANCH: process.env.BRANCH, GIT_SHA: process.env.GIT_SHA, ENABLE_WARMUP_HOTEL: process.env.ENABLE_WARMUP_HOTEL, + WARMUP_TOKEN: process.env.WARMUP_TOKEN, }, }) diff --git a/apps/scandic-web/netlify/functions/hoteldata-da-background.mts b/apps/scandic-web/netlify/functions/hoteldata-da-background.mts deleted file mode 100644 index d1483376b..000000000 --- a/apps/scandic-web/netlify/functions/hoteldata-da-background.mts +++ /dev/null @@ -1,12 +0,0 @@ -import { Lang } from "@/constants/languages" -import type { Config, Context } from "@netlify/functions" -import { warmupHotelDataOnLang } from "../utils/hoteldata" - -export default async (_request: Request, _context: Context) => { - await warmupHotelDataOnLang(Lang.da) - return new Response("Warmup success", { status: 200 }) -} - -export const config: Config = { - schedule: "0 4 * * *", -} diff --git a/apps/scandic-web/netlify/functions/hoteldata-de-background.mts b/apps/scandic-web/netlify/functions/hoteldata-de-background.mts deleted file mode 100644 index ea10b2e07..000000000 --- a/apps/scandic-web/netlify/functions/hoteldata-de-background.mts +++ /dev/null @@ -1,12 +0,0 @@ -import { Lang } from "@/constants/languages" -import type { Config, Context } from "@netlify/functions" -import { warmupHotelDataOnLang } from "../utils/hoteldata" - -export default async (_request: Request, _context: Context) => { - await warmupHotelDataOnLang(Lang.de) - return new Response("Warmup success", { status: 200 }) -} - -export const config: Config = { - schedule: "5 4 * * *", -} diff --git a/apps/scandic-web/netlify/functions/hoteldata-en-background.mts b/apps/scandic-web/netlify/functions/hoteldata-en-background.mts deleted file mode 100644 index bb82dc121..000000000 --- a/apps/scandic-web/netlify/functions/hoteldata-en-background.mts +++ /dev/null @@ -1,12 +0,0 @@ -import { Lang } from "@/constants/languages" -import type { Config, Context } from "@netlify/functions" -import { warmupHotelDataOnLang } from "../utils/hoteldata" - -export default async (_request: Request, _context: Context) => { - await warmupHotelDataOnLang(Lang.en) - return new Response("Warmup success", { status: 200 }) -} - -export const config: Config = { - schedule: "10 4 * * *", -} diff --git a/apps/scandic-web/netlify/functions/hoteldata-fi-background.mts b/apps/scandic-web/netlify/functions/hoteldata-fi-background.mts deleted file mode 100644 index 8f1d45e8d..000000000 --- a/apps/scandic-web/netlify/functions/hoteldata-fi-background.mts +++ /dev/null @@ -1,12 +0,0 @@ -import { Lang } from "@/constants/languages" -import type { Config, Context } from "@netlify/functions" -import { warmupHotelDataOnLang } from "../utils/hoteldata" - -export default async (_request: Request, _context: Context) => { - await warmupHotelDataOnLang(Lang.fi) - return new Response("Warmup success", { status: 200 }) -} - -export const config: Config = { - schedule: "15 4 * * *", -} diff --git a/apps/scandic-web/netlify/functions/hoteldata-no-background.mts b/apps/scandic-web/netlify/functions/hoteldata-no-background.mts deleted file mode 100644 index 2e2eecd4c..000000000 --- a/apps/scandic-web/netlify/functions/hoteldata-no-background.mts +++ /dev/null @@ -1,12 +0,0 @@ -import { Lang } from "@/constants/languages" -import type { Config, Context } from "@netlify/functions" -import { warmupHotelDataOnLang } from "../utils/hoteldata" - -export default async (_request: Request, _context: Context) => { - await warmupHotelDataOnLang(Lang.no) - return new Response("Warmup success", { status: 200 }) -} - -export const config: Config = { - schedule: "20 4 * * *", -} diff --git a/apps/scandic-web/netlify/functions/hoteldata-sv-background.mts b/apps/scandic-web/netlify/functions/hoteldata-sv-background.mts deleted file mode 100644 index 2aeae74a7..000000000 --- a/apps/scandic-web/netlify/functions/hoteldata-sv-background.mts +++ /dev/null @@ -1,12 +0,0 @@ -import { Lang } from "@/constants/languages" -import type { Config, Context } from "@netlify/functions" -import { warmupHotelDataOnLang } from "../utils/hoteldata" - -export default async (_request: Request, _context: Context) => { - await warmupHotelDataOnLang(Lang.sv) - return new Response("Warmup success", { status: 200 }) -} - -export const config: Config = { - schedule: "25 4 * * *", -} diff --git a/apps/scandic-web/netlify/functions/warmup-background.mts b/apps/scandic-web/netlify/functions/warmup-background.mts new file mode 100644 index 000000000..0e95318a3 --- /dev/null +++ b/apps/scandic-web/netlify/functions/warmup-background.mts @@ -0,0 +1,249 @@ +import crypto from "crypto" +import jwt from "jsonwebtoken" + +import { + type WarmupFunctionsKey, + warmupKeys, +} from "@/services/warmup/warmupKeys" +import { safeTry } from "@/utils/safeTry" +import { timeout } from "@/utils/timeout" + +import type { Config, Context } from "@netlify/functions" + +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: + console.warn("[WARMUP] Warmup is disabled") + return + case ErrorCodes.URLS_DONT_MATCH: + // This is expected, this webhook will be called for all deployments + // and we only want to warmup the ones that match our URL + return + } + } + + console.error("[WARMUP] Warmup failed", error) + return + } + + console.log("[WARMUP] Request is valid, starting warmup") + await performWarmup(context) + console.log("[WARMUP] Warmup completed") +} + +async function validateRequest( + request: Request, + context: Context +): Promise { + if (request.method !== "POST") { + throw new Error(ErrorCodes.METHOD_NOT_ALLOWED) + } + + const warmupEnabled = + true || + 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() + console.log("[WARMUP] Warmup body", body) + const deployment = JSON.parse(body) as DeploymentInfo + if (!deployment) { + throw new Error(ErrorCodes.UNABLE_TO_PARSE_DEPLOYMENT_INFO) + } + + const deployedUrl = deployment.deploy_ssl_url + if (deployedUrl !== context.url.origin) { + throw new Error(ErrorCodes.URLS_DONT_MATCH) + } + + console.log("[WARMUP] Warmup request", deployment) + 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) { + console.warn("[WARMUP] 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) { + console.log("[WARMUP] Warming up cache", key) + await callWarmup(key, context) + // allow api to catch up + await timeout(1000) + } +} + +async function callWarmup(key: WarmupFunctionsKey, 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}`, + }, + }) + + if (!response.ok) { + console.error( + `[WARMUP] Warmup failed '${url.href}' with error: ${response.status}: ${response.statusText}` + ) + } + } catch (error) { + console.error( + `[WARMUP] Warmup failed '${url.href}' with error: ${error instanceof Error ? error.message : error}` + ) + } +} + +export default WarmupHandler +export const config: Config = { + method: "POST", +} + +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)) { + console.error( + "[WARMUP] Decoded jwt does not contain sha256, unable to verify signature" + ) + return false + } + + return decoded.sha256 === hashedBody + } catch (error) { + console.error("[WARMUP] 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" + ) +} + +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: any[] + required_functions: any[] + commit_ref: string + review_id: string | null + branch: string + commit_url: string + skipped: any + locked: any + 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: any + manual_deploy: boolean + plugin_state: string + lighthouse_plugin_scores: any + 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: any + 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", + URLS_DONT_MATCH: "URLS DONT MATCH", + WARMUP_DISABLED: "WARMUP IS DISABLED", +} as const +type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes] diff --git a/apps/scandic-web/netlify/functions/warmup-scheduled-background.mts b/apps/scandic-web/netlify/functions/warmup-scheduled-background.mts new file mode 100644 index 000000000..b4e6c7f76 --- /dev/null +++ b/apps/scandic-web/netlify/functions/warmup-scheduled-background.mts @@ -0,0 +1,14 @@ +import { performWarmup } from "./warmup-background.mjs" + +import type { Config, Context } from "@netlify/functions" + +async function WarmupHandler(_request: Request, context: Context) { + console.log("[WARMUP] Starting scheduled warmup") + await performWarmup(context) + console.log("[WARMUP] Scheduled warmup completed") +} + +export default WarmupHandler +export const config: Config = { + schedule: "0 4 * * *", +} diff --git a/apps/scandic-web/package.json b/apps/scandic-web/package.json index 475b61e7e..f57781062 100644 --- a/apps/scandic-web/package.json +++ b/apps/scandic-web/package.json @@ -88,6 +88,7 @@ "input-otp": "^1.4.2", "ioredis": "^5.5.0", "json-stable-stringify-without-jsonify": "^1.0.1", + "jsonwebtoken": "^9.0.2", "libphonenumber-js": "^1.10.60", "lodash-es": "^4.17.21", "nanoid": "^5.0.9", @@ -125,6 +126,7 @@ "@types/adm-zip": "^0.5.7", "@types/jest": "^29.5.12", "@types/json-stable-stringify-without-jsonify": "^1.0.2", + "@types/jsonwebtoken": "^9", "@types/lodash-es": "^4", "@types/node": "^20", "@types/react": "^18", diff --git a/apps/scandic-web/server/routers/hotels/query.ts b/apps/scandic-web/server/routers/hotels/query.ts index 5090d83c4..250273556 100644 --- a/apps/scandic-web/server/routers/hotels/query.ts +++ b/apps/scandic-web/server/routers/hotels/query.ts @@ -666,6 +666,7 @@ export const hotelQueryRouter = router({ lang: lang, serviceToken: ctx.serviceToken, }) + return hotels }) ) @@ -674,15 +675,14 @@ export const hotelQueryRouter = router({ return hotelData } - if (warmup) { - return await fetchHotels() - } - const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${lang}:getDestinationsMapData`, fetchHotels, - "max" + "max", + { + cacheStrategy: warmup ? "fetch-then-cache" : "cache-first", + } ) }), }), diff --git a/apps/scandic-web/server/routers/hotels/utils.ts b/apps/scandic-web/server/routers/hotels/utils.ts index 5b1c16354..b7a6d329d 100644 --- a/apps/scandic-web/server/routers/hotels/utils.ts +++ b/apps/scandic-web/server/routers/hotels/utils.ts @@ -130,9 +130,11 @@ export async function getCity({ export async function getCountries({ lang, serviceToken, + warmup = false, }: { lang: Lang serviceToken: string + warmup?: boolean }) { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( @@ -166,7 +168,10 @@ export async function getCountries({ return countries.data }, - "1d" + "1d", + { + cacheStrategy: warmup ? "fetch-then-cache" : "cache-first", + } ) } diff --git a/apps/scandic-web/services/dataCache/Cache.ts b/apps/scandic-web/services/dataCache/Cache.ts index 93df84b1a..97cc6bb05 100644 --- a/apps/scandic-web/services/dataCache/Cache.ts +++ b/apps/scandic-web/services/dataCache/Cache.ts @@ -1,3 +1,5 @@ +import type { CacheOrGetOptions } from "./cacheOrGetOptions" + const ONE_HOUR_IN_SECONDS = 3_600 as const const ONE_DAY_IN_SECONDS = 86_400 as const @@ -60,6 +62,7 @@ export type DataCache = { * @param key The cache key * @param getDataFromSource An async function that provides a value to cache * @param ttl Time to live, either a named cache time or a number of seconds + * @param opts Options to control cache behavior when retrieving or storing data. * @returns The cached value or the result from the callback */ cacheOrGet: ( @@ -67,7 +70,8 @@ export type DataCache = { getDataFromSource: ( overrideTTL?: (cacheTime: CacheTime) => void ) => Promise, - ttl: CacheTime + ttl: CacheTime, + opts?: CacheOrGetOptions ) => Promise /** diff --git a/apps/scandic-web/services/dataCache/DistributedCache/cacheOrGet.ts b/apps/scandic-web/services/dataCache/DistributedCache/cacheOrGet.ts index dcab4d279..e4850b109 100644 --- a/apps/scandic-web/services/dataCache/DistributedCache/cacheOrGet.ts +++ b/apps/scandic-web/services/dataCache/DistributedCache/cacheOrGet.ts @@ -1,5 +1,9 @@ import { type CacheTime, type DataCache } from "@/services/dataCache/Cache" +import { + type CacheOrGetOptions, + shouldGetFromCache, +} from "../cacheOrGetOptions" import { cacheLogger } from "../logger" import { generateCacheKey } from "./generateCacheKey" import { get } from "./get" @@ -8,10 +12,15 @@ import { set } from "./set" export const cacheOrGet: DataCache["cacheOrGet"] = async ( key: string | string[], callback: (overrideTTL: (cacheTime: CacheTime) => void) => Promise, - ttl: CacheTime + ttl: CacheTime, + opts?: CacheOrGetOptions ) => { const cacheKey = generateCacheKey(key) - const cachedValue = await get(cacheKey) + + let cachedValue: Awaited | undefined = undefined + if (shouldGetFromCache(opts)) { + cachedValue = await get(cacheKey) + } let realTTL = ttl diff --git a/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheOrGet.ts b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheOrGet.ts index 6a9ff00b8..4342e968d 100644 --- a/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheOrGet.ts +++ b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheOrGet.ts @@ -1,13 +1,18 @@ import { type CacheTime, type DataCache } from "@/services/dataCache/Cache" import { cacheLogger } from "@/services/dataCache/logger" +import { + type CacheOrGetOptions, + shouldGetFromCache, +} from "../../cacheOrGetOptions" import { get } from "./get" import { set } from "./set" export const cacheOrGet: DataCache["cacheOrGet"] = async ( key: string | string[], callback: (overrideTTL?: (cacheTime: CacheTime) => void) => Promise, - ttl: CacheTime + ttl: CacheTime, + opts?: CacheOrGetOptions ): Promise => { if (Array.isArray(key)) { key = key.join("-") @@ -18,14 +23,16 @@ export const cacheOrGet: DataCache["cacheOrGet"] = async ( realTTL = cacheTime } - const cached = await get(key) + let cached: Awaited | undefined = undefined + if (shouldGetFromCache(opts)) { + cached = await get(key) + if (cached) { + return cached + } - if (cached) { - return cached as T + cacheLogger.debug(`Miss for key '${key}'`) } - cacheLogger.debug(`Miss for key '${key}'`) - try { const data = await callback(overrideTTL) await set(key, data, realTTL) diff --git a/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/cacheOrGet.ts b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/cacheOrGet.ts index 04943fe45..b9539f1b4 100644 --- a/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/cacheOrGet.ts +++ b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/cacheOrGet.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from "next/cache" +import { revalidateTag, unstable_cache } from "next/cache" import { type CacheTime, @@ -6,18 +6,26 @@ import { getCacheTimeInSeconds, } from "@/services/dataCache/Cache" +import { + type CacheOrGetOptions, + shouldGetFromCache, +} from "../../cacheOrGetOptions" import { cacheLogger } from "../../logger" export const cacheOrGet: DataCache["cacheOrGet"] = async ( key: string | string[], callback: () => Promise, - ttl: CacheTime + ttl: CacheTime, + opts?: CacheOrGetOptions ): Promise => { if (!Array.isArray(key)) { key = [key] } const perf = performance.now() + if (!shouldGetFromCache(opts)) { + revalidateTag(key[0]) + } const res = await unstable_cache(callback, key, { revalidate: getCacheTimeInSeconds(ttl), diff --git a/apps/scandic-web/services/dataCache/cacheOrGetOptions.ts b/apps/scandic-web/services/dataCache/cacheOrGetOptions.ts new file mode 100644 index 000000000..afb66de45 --- /dev/null +++ b/apps/scandic-web/services/dataCache/cacheOrGetOptions.ts @@ -0,0 +1,27 @@ +/** + * Options to control cache behavior when retrieving or storing data. + * + * - "cache-first": Default behaviour, check if the needed data is available in the cache first. If the data is found, it is returned immediately. Otherwise, the data is fetched and then cached. + * - "fetch-then-cache": Always fetch the data first, and then update the cache with the freshly fetched data. + */ +export type CacheStrategy = "cache-first" | "fetch-then-cache" +export type CacheOrGetOptions = { + cacheStrategy?: CacheStrategy +} + +export function defaultCacheOrGetOptions( + opts: CacheOrGetOptions = {} +): CacheOrGetOptions { + return { + cacheStrategy: "cache-first", + ...opts, + } +} + +export function shouldGetFromCache( + opts: CacheOrGetOptions | undefined +): boolean { + opts = defaultCacheOrGetOptions(opts) + + return opts.cacheStrategy === "cache-first" +} diff --git a/apps/scandic-web/services/dataCache/index.ts b/apps/scandic-web/services/dataCache/index.ts index acedfdee2..c6463b932 100644 --- a/apps/scandic-web/services/dataCache/index.ts +++ b/apps/scandic-web/services/dataCache/index.ts @@ -3,11 +3,10 @@ import { env } from "@/env/server" import { isEdge } from "@/utils/isEdge" import { createMemoryCache } from "./MemoryCache/createMemoryCache" +import { type DataCache } from "./Cache" import { createDistributedCache } from "./DistributedCache" import { cacheLogger } from "./logger" -import type { DataCache } from "./Cache" - export type { CacheTime, DataCache } from "./Cache" export async function getCacheClient(): Promise { diff --git a/apps/scandic-web/services/warmup/index.ts b/apps/scandic-web/services/warmup/index.ts new file mode 100644 index 000000000..013574eb6 --- /dev/null +++ b/apps/scandic-web/services/warmup/index.ts @@ -0,0 +1,50 @@ +import { Lang } from "@/constants/languages" + +import { warmupCountry } from "./warmupCountries" +import { warmupHotelData } from "./warmupHotelData" +import { warmupHotelIdsByCountry } from "./warmupHotelIdsByCountry" + +import type { WarmupFunctionsKey } from "./warmupKeys" + +export type WarmupFunction = () => Promise + +type BaseWarmup = { + status: "skipped" | "completed" +} + +type FailedWarmup = { + status: "error" + error: Error +} + +export type WarmupResult = BaseWarmup | FailedWarmup + +export const warmupFunctions: Record = { + countries_en: warmupCountry(Lang.en), + countries_da: warmupCountry(Lang.da), + countries_de: warmupCountry(Lang.de), + countries_fi: warmupCountry(Lang.fi), + countries_sv: warmupCountry(Lang.sv), + countries_no: warmupCountry(Lang.no), + + hotelsByCountry: warmupHotelIdsByCountry(), + + hotelData_en: warmupHotelData(Lang.en), + hotelData_da: warmupHotelData(Lang.da), + hotelData_de: warmupHotelData(Lang.de), + hotelData_fi: warmupHotelData(Lang.fi), + hotelData_sv: warmupHotelData(Lang.sv), + hotelData_no: warmupHotelData(Lang.no), +} + +export async function warmup(key: WarmupFunctionsKey): Promise { + const func = warmupFunctions[key] + if (!func) { + return { + status: "error", + error: new Error(`Warmup function ${key} not found`), + } + } + + return func() +} diff --git a/apps/scandic-web/services/warmup/warmupCountries.ts b/apps/scandic-web/services/warmup/warmupCountries.ts new file mode 100644 index 000000000..45927d06f --- /dev/null +++ b/apps/scandic-web/services/warmup/warmupCountries.ts @@ -0,0 +1,28 @@ +import { getCountries } from "@/server/routers/hotels/utils" +import { getServiceToken } from "@/server/tokenManager" + +import type { Lang } from "@/constants/languages" +import type { WarmupFunction, WarmupResult } from "." + +export const warmupCountry = + (lang: Lang): WarmupFunction => + async (): Promise => { + try { + const serviceToken = await getServiceToken() + + await getCountries({ + lang: lang, + serviceToken: serviceToken.access_token, + warmup: true, + }) + } catch (error) { + return { + status: "error", + error: error as Error, + } + } + + return { + status: "completed", + } + } diff --git a/apps/scandic-web/services/warmup/warmupHotelData.ts b/apps/scandic-web/services/warmup/warmupHotelData.ts new file mode 100644 index 000000000..1c9698dc2 --- /dev/null +++ b/apps/scandic-web/services/warmup/warmupHotelData.ts @@ -0,0 +1,31 @@ +import { env } from "@/env/server" +import { serverClient } from "@/lib/trpc/server" + +import type { Lang } from "@/constants/languages" +import type { WarmupFunction, WarmupResult } from "." + +export const warmupHotelData = + (lang: Lang): WarmupFunction => + async (): Promise => { + if (!env.ENABLE_WARMUP_HOTEL) { + return { + status: "skipped", + } + } + + try { + await serverClient().hotel.hotels.getDestinationsMapData({ + lang, + warmup: true, + }) + } catch (error) { + return { + status: "error", + error: error as Error, + } + } + + return { + status: "completed", + } + } diff --git a/apps/scandic-web/services/warmup/warmupHotelIdsByCountry.ts b/apps/scandic-web/services/warmup/warmupHotelIdsByCountry.ts new file mode 100644 index 000000000..f0e6ace33 --- /dev/null +++ b/apps/scandic-web/services/warmup/warmupHotelIdsByCountry.ts @@ -0,0 +1,60 @@ +import { Lang } from "@/constants/languages" +import { + getCountries, + getHotelIdsByCountry, +} from "@/server/routers/hotels/utils" +import { getServiceToken } from "@/server/tokenManager" + +import { safeTry } from "@/utils/safeTry" + +import type { WarmupFunction, WarmupResult } from "." + +export const warmupHotelIdsByCountry = + (): WarmupFunction => async (): Promise => { + try { + let serviceToken = await getServiceToken() + + const [countries, countriesError] = await safeTry( + getCountries({ + lang: Lang.en, + serviceToken: serviceToken.access_token, + warmup: true, + }) + ) + + if (!countries || countriesError) { + return { + status: "error", + error: new Error("Unable to get countries"), + } + } + + const countryNames = countries.data.map((country) => country.name) + for (const countryName of countryNames) { + serviceToken = await getServiceToken() + const [_, error] = await safeTry( + getHotelIdsByCountry({ + country: countryName, + serviceToken: serviceToken.access_token, + }) + ) + + if (error) { + console.error( + `[Warmup]: Error fetching hotel IDs for ${countryName}:`, + error + ) + continue + } + } + + return { + status: "completed", + } + } catch (error) { + return { + status: "error", + error: error as Error, + } + } + } diff --git a/apps/scandic-web/services/warmup/warmupKeys.ts b/apps/scandic-web/services/warmup/warmupKeys.ts new file mode 100644 index 000000000..ee8a06def --- /dev/null +++ b/apps/scandic-web/services/warmup/warmupKeys.ts @@ -0,0 +1,20 @@ +import { Lang } from "@/constants/languages" + +const langs = Object.keys(Lang) as Lang[] + +/* + * Keys for warmup functions, the order of the keys is the order in which they will be executed + */ +export const warmupKeys = [ + ...langs.map((lang) => `countries_${lang}` as const), + "hotelsByCountry", + ...langs.map((lang) => `hotelData_${lang}` as const), +] as const + +export type WarmupFunctionsKey = (typeof warmupKeys)[number] + +export function isWarmupKey(key: unknown): key is WarmupFunctionsKey { + return ( + typeof key === "string" && warmupKeys.includes(key as WarmupFunctionsKey) + ) +} diff --git a/apps/scandic-web/utils/logger/index.ts b/apps/scandic-web/utils/logger/index.ts new file mode 100644 index 000000000..aa125c766 --- /dev/null +++ b/apps/scandic-web/utils/logger/index.ts @@ -0,0 +1,16 @@ +export function createLogger(loggerPrefix: string | (() => Promise)) { + const getLoggerPrefix: () => Promise = + typeof loggerPrefix === "string" ? async () => loggerPrefix : loggerPrefix + + return { + async debug(message: string, ...args: unknown[]): Promise { + console.debug(`[${await getLoggerPrefix()}] ${message}`, ...args) + }, + async warn(message: string, ...args: unknown[]): Promise { + console.warn(`[${await getLoggerPrefix()}] Warning - ${message}`, ...args) + }, + async error(message: string, ...args: unknown[]): Promise { + console.error(`[${await getLoggerPrefix()}] Error - ${message}`, ...args) + }, + } +} diff --git a/yarn.lock b/yarn.lock index b9c264678..296b20cbc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6402,6 +6402,7 @@ __metadata: "@types/geojson": "npm:^7946.0.16" "@types/jest": "npm:^29.5.12" "@types/json-stable-stringify-without-jsonify": "npm:^1.0.2" + "@types/jsonwebtoken": "npm:^9" "@types/lodash-es": "npm:^4" "@types/node": "npm:^20" "@types/react": "npm:^18" @@ -6446,6 +6447,7 @@ __metadata: jiti: "npm:^1.21.0" json-sort-cli: "npm:^4.0.4" json-stable-stringify-without-jsonify: "npm:^1.0.1" + jsonwebtoken: "npm:^9.0.2" libphonenumber-js: "npm:^1.10.60" lint-staged: "npm:^15.2.2" lodash-es: "npm:^4.17.21" @@ -8221,6 +8223,16 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:^9": + version: 9.0.9 + resolution: "@types/jsonwebtoken@npm:9.0.9" + dependencies: + "@types/ms": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/d754a7b65fc021b298fc94e8d7a7d71f35dedf24296ac89286f80290abc5dbb0c7830a21440ee9ecbb340efc1b0a21f5609ea298a35b874cae5ad29a65440741 + languageName: node + linkType: hard + "@types/keyv@npm:^3.1.1, @types/keyv@npm:^3.1.4": version: 3.1.4 resolution: "@types/keyv@npm:3.1.4" @@ -8253,6 +8265,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 2.1.0 + resolution: "@types/ms@npm:2.1.0" + checksum: 10c0/5ce692ffe1549e1b827d99ef8ff71187457e0eb44adbae38fdf7b9a74bae8d20642ee963c14516db1d35fa2652e65f47680fdf679dcbde52bbfadd021f497225 + languageName: node + linkType: hard + "@types/mysql@npm:2.15.26": version: 2.15.26 resolution: "@types/mysql@npm:2.15.26" @@ -10005,6 +10024,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 10c0/fb2294e64d23c573d0dd1f1e7a466c3e978fe94a4e0f8183937912ca374619773bef8e2aceb854129d2efecbbc515bbd0cc78d2734a3e3031edb0888531bbc8e + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -11609,6 +11635,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/ebfbf19d4b8be938f4dd4a83b8788385da353d63307ede301a9252f9f7f88672e76f2191618fd8edfc2f24679236064176fab0b78131b161ee73daa37125408c + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -15400,6 +15435,24 @@ __metadata: languageName: node linkType: hard +"jsonwebtoken@npm:^9.0.2": + version: 9.0.2 + resolution: "jsonwebtoken@npm:9.0.2" + dependencies: + jws: "npm:^3.2.2" + lodash.includes: "npm:^4.3.0" + lodash.isboolean: "npm:^3.0.3" + lodash.isinteger: "npm:^4.0.4" + lodash.isnumber: "npm:^3.0.3" + lodash.isplainobject: "npm:^4.0.6" + lodash.isstring: "npm:^4.0.1" + lodash.once: "npm:^4.0.0" + ms: "npm:^2.1.1" + semver: "npm:^7.5.4" + checksum: 10c0/d287a29814895e866db2e5a0209ce730cbc158441a0e5a70d5e940eb0d28ab7498c6bf45029cc8b479639bca94056e9a7f254e2cdb92a2f5750c7f358657a131 + languageName: node + linkType: hard + "jsprim@npm:^2.0.2": version: 2.0.2 resolution: "jsprim@npm:2.0.2" @@ -15424,6 +15477,27 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^1.4.1": + version: 1.4.1 + resolution: "jwa@npm:1.4.1" + dependencies: + buffer-equal-constant-time: "npm:1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/5c533540bf38702e73cf14765805a94027c66a0aa8b16bc3e89d8d905e61a4ce2791e87e21be97d1293a5ee9d4f3e5e47737e671768265ca4f25706db551d5e9 + languageName: node + linkType: hard + +"jws@npm:^3.2.2": + version: 3.2.2 + resolution: "jws@npm:3.2.2" + dependencies: + jwa: "npm:^1.4.1" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/e770704533d92df358adad7d1261fdecad4d7b66fa153ba80d047e03ca0f1f73007ce5ed3fbc04d2eba09ba6e7e6e645f351e08e5ab51614df1b0aa4f384dfff + languageName: node + linkType: hard + "kdbush@npm:^4.0.2": version: 4.0.2 resolution: "kdbush@npm:4.0.2" @@ -15899,6 +15973,13 @@ __metadata: languageName: node linkType: hard +"lodash.includes@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.includes@npm:4.3.0" + checksum: 10c0/7ca498b9b75bf602d04e48c0adb842dfc7d90f77bcb2a91a2b2be34a723ad24bc1c8b3683ec6b2552a90f216c723cdea530ddb11a3320e08fa38265703978f4b + languageName: node + linkType: hard + "lodash.isarguments@npm:^3.1.0": version: 3.1.0 resolution: "lodash.isarguments@npm:3.1.0" @@ -15906,6 +15987,13 @@ __metadata: languageName: node linkType: hard +"lodash.isboolean@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isboolean@npm:3.0.3" + checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 + languageName: node + linkType: hard + "lodash.isempty@npm:^4.4.0": version: 4.4.0 resolution: "lodash.isempty@npm:4.4.0" @@ -15913,6 +16001,20 @@ __metadata: languageName: node linkType: hard +"lodash.isinteger@npm:^4.0.4": + version: 4.0.4 + resolution: "lodash.isinteger@npm:4.0.4" + checksum: 10c0/4c3e023a2373bf65bf366d3b8605b97ec830bca702a926939bcaa53f8e02789b6a176e7f166b082f9365bfec4121bfeb52e86e9040cb8d450e64c858583f61b7 + languageName: node + linkType: hard + +"lodash.isnumber@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isnumber@npm:3.0.3" + checksum: 10c0/2d01530513a1ee4f72dd79528444db4e6360588adcb0e2ff663db2b3f642d4bb3d687051ae1115751ca9082db4fdef675160071226ca6bbf5f0c123dbf0aa12d + languageName: node + linkType: hard + "lodash.isplainobject@npm:^4.0.6": version: 4.0.6 resolution: "lodash.isplainobject@npm:4.0.6" @@ -15920,6 +16022,13 @@ __metadata: languageName: node linkType: hard +"lodash.isstring@npm:^4.0.1": + version: 4.0.1 + resolution: "lodash.isstring@npm:4.0.1" + checksum: 10c0/09eaf980a283f9eef58ef95b30ec7fee61df4d6bf4aba3b5f096869cc58f24c9da17900febc8ffd67819b4e29de29793190e88dc96983db92d84c95fa85d1c92 + languageName: node + linkType: hard + "lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -15934,7 +16043,7 @@ __metadata: languageName: node linkType: hard -"lodash.once@npm:^4.1.1": +"lodash.once@npm:^4.0.0, lodash.once@npm:^4.1.1": version: 4.1.1 resolution: "lodash.once@npm:4.1.1" checksum: 10c0/46a9a0a66c45dd812fcc016e46605d85ad599fe87d71a02f6736220554b52ffbe82e79a483ad40f52a8a95755b0d1077fba259da8bfb6694a7abbf4a48f1fc04