diff --git a/apps/sample-website/package.json b/apps/sample-website/package.json index e31fcdf..ea50b4d 100644 --- a/apps/sample-website/package.json +++ b/apps/sample-website/package.json @@ -22,6 +22,9 @@ }, "dependencies": { "@mintel/next-utils": "workspace:*", + "@mintel/observability": "workspace:*", + "@mintel/next-observability": "workspace:*", + "@sentry/nextjs": "^8.55.0", "next": "15.1.6", "next-intl": "^4.8.2", "react": "^19.0.0", diff --git a/apps/sample-website/sentry.client.config.ts b/apps/sample-website/sentry.client.config.ts new file mode 100644 index 0000000..bf6033d --- /dev/null +++ b/apps/sample-website/sentry.client.config.ts @@ -0,0 +1,9 @@ +import { initSentry } from "@mintel/next-observability"; + +initSentry({ + // Use a placeholder DSN on the client if you want to bypass ad-blockers via tunnel + // Or just use the real DSN if you don't care about ad-blockers for errors. + // The Mintel standard is to use the relay. + dsn: "https://public@errors.infra.mintel.me/1", // Placeholder for relay + tunnel: "/errors/api/relay", +}); diff --git a/apps/sample-website/sentry.edge.config.ts b/apps/sample-website/sentry.edge.config.ts new file mode 100644 index 0000000..6b943bc --- /dev/null +++ b/apps/sample-website/sentry.edge.config.ts @@ -0,0 +1,8 @@ +import { initSentry } from "@mintel/next-observability"; +import { validateMintelEnv } from "@mintel/next-utils"; + +const env = validateMintelEnv(); + +initSentry({ + dsn: env.SENTRY_DSN, +}); diff --git a/apps/sample-website/sentry.server.config.ts b/apps/sample-website/sentry.server.config.ts new file mode 100644 index 0000000..6b943bc --- /dev/null +++ b/apps/sample-website/sentry.server.config.ts @@ -0,0 +1,8 @@ +import { initSentry } from "@mintel/next-observability"; +import { validateMintelEnv } from "@mintel/next-utils"; + +const env = validateMintelEnv(); + +initSentry({ + dsn: env.SENTRY_DSN, +}); diff --git a/apps/sample-website/src/app/errors/api/relay/route.ts b/apps/sample-website/src/app/errors/api/relay/route.ts new file mode 100644 index 0000000..80b734a --- /dev/null +++ b/apps/sample-website/src/app/errors/api/relay/route.ts @@ -0,0 +1,6 @@ +import { createSentryRelayHandler } from "@mintel/next-observability"; +import { validateMintelEnv } from "@mintel/next-utils"; + +export const POST = createSentryRelayHandler({ + dsn: validateMintelEnv().SENTRY_DSN, +}); diff --git a/apps/sample-website/src/app/layout.tsx b/apps/sample-website/src/app/layout.tsx index 7b7c69c..ac41ea6 100644 --- a/apps/sample-website/src/app/layout.tsx +++ b/apps/sample-website/src/app/layout.tsx @@ -1,5 +1,11 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import "./globals.css"; +import { + AnalyticsContextProvider, + AnalyticsAutoTracker, +} from "@mintel/next-observability/client"; +import { getAnalyticsConfig } from "@/lib/observability"; export const metadata: Metadata = { title: "Sample Website", @@ -11,9 +17,18 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const analyticsConfig = getAnalyticsConfig(); + return ( - {children} + + + + + + {children} + + ); } diff --git a/apps/sample-website/src/app/stats/api/send/route.ts b/apps/sample-website/src/app/stats/api/send/route.ts new file mode 100644 index 0000000..cd0d0cd --- /dev/null +++ b/apps/sample-website/src/app/stats/api/send/route.ts @@ -0,0 +1,7 @@ +import { createUmamiProxyHandler } from "@mintel/next-observability"; +import { validateMintelEnv } from "@mintel/next-utils"; + +export const POST = createUmamiProxyHandler({ + websiteId: validateMintelEnv().UMAMI_WEBSITE_ID, + apiEndpoint: validateMintelEnv().UMAMI_API_ENDPOINT, +}); diff --git a/apps/sample-website/src/instrumentation.ts b/apps/sample-website/src/instrumentation.ts new file mode 100644 index 0000000..ecb6528 --- /dev/null +++ b/apps/sample-website/src/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from "@sentry/nextjs"; + +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("../sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("../sentry.edge.config"); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/apps/sample-website/src/lib/observability.ts b/apps/sample-website/src/lib/observability.ts new file mode 100644 index 0000000..b2a82e6 --- /dev/null +++ b/apps/sample-website/src/lib/observability.ts @@ -0,0 +1,54 @@ +import { + UmamiAnalyticsService, + GotifyNotificationService, + NoopNotificationService, +} from "@mintel/observability"; +import { validateMintelEnv } from "@mintel/next-utils"; + +let analyticsService: any = null; +let notificationService: any = null; + +export function getAnalyticsConfig() { + const isClient = typeof window !== "undefined"; + + if (isClient) { + return { + enabled: true, + apiEndpoint: "/stats", + }; + } + + const env = validateMintelEnv(); + return { + enabled: Boolean(env.UMAMI_WEBSITE_ID), + websiteId: env.UMAMI_WEBSITE_ID, + apiEndpoint: env.UMAMI_API_ENDPOINT, + }; +} + +export function getAnalyticsService() { + if (analyticsService) return analyticsService; + + const config = getAnalyticsConfig(); + analyticsService = new UmamiAnalyticsService(config); + + return analyticsService; +} + +export function getNotificationService() { + if (notificationService) return notificationService; + + if (typeof window === "undefined") { + const env = validateMintelEnv(); + notificationService = new GotifyNotificationService({ + enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN), + url: env.GOTIFY_URL || "", + token: env.GOTIFY_TOKEN || "", + }); + } else { + // Notifications are typically server-side only to protect tokens + notificationService = new NoopNotificationService(); + } + + return notificationService; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a9afb73..35b7811 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -125,6 +125,7 @@ program react: "^19.0.0", "react-dom": "^19.0.0", "@mintel/next-utils": "workspace:*", + "@mintel/next-observability": "workspace:*", "@directus/sdk": "^21.0.0", }, devDependencies: { @@ -263,11 +264,15 @@ export default createMintelI18nRequestConfig( // Create instrumentation.ts await fs.writeFile( path.join(fullPath, "src/instrumentation.ts"), - `import * as Sentry from '@sentry/nextjs'; + `import { Sentry } from '@mintel/next-observability'; export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { - // Server-side initialization + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); } } @@ -397,8 +402,12 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass SENTRY_DSN= # Analytics (Umami) -NEXT_PUBLIC_UMAMI_WEBSITE_ID= -NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js +UMAMI_WEBSITE_ID= +UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me + +# Notifications (Gotify) +GOTIFY_URL= +GOTIFY_TOKEN= `; await fs.writeFile(path.join(fullPath, ".env.example"), envExample); diff --git a/packages/next-observability/package.json b/packages/next-observability/package.json new file mode 100644 index 0000000..e409054 --- /dev/null +++ b/packages/next-observability/package.json @@ -0,0 +1,49 @@ +{ + "name": "@mintel/next-observability", + "version": "1.0.0", + "publishConfig": { + "access": "public", + "registry": "https://npm.infra.mintel.me" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js", + "require": "./dist/client.cjs" + } + }, + "type": "module", + "scripts": { + "build": "tsup src/index.ts src/client.ts --format cjs,esm --dts --splitting", + "dev": "tsup src/index.ts src/client.ts --format cjs,esm --watch --dts --splitting", + "lint": "eslint src/" + }, + "dependencies": { + "@mintel/observability": "workspace:*", + "@sentry/nextjs": "^8.55.0", + "next": "15.1.6" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@mintel/eslint-config": "workspace:*", + "@mintel/tsconfig": "workspace:*", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "eslint": "^9.39.2", + "tsup": "^8.0.0", + "typescript": "^5.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/next-observability/src/analytics/auto-tracker.tsx b/packages/next-observability/src/analytics/auto-tracker.tsx new file mode 100644 index 0000000..c2b336d --- /dev/null +++ b/packages/next-observability/src/analytics/auto-tracker.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useAnalytics } from "./context"; + +/** + * Automatically tracks pageviews on client-side route changes in Next.js. + */ +export function AnalyticsAutoTracker() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const analytics = useAnalytics(); + + useEffect(() => { + if (!pathname) return; + + const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`; + analytics.trackPageview(url); + }, [pathname, searchParams, analytics]); + + return null; +} diff --git a/packages/next-observability/src/analytics/context.tsx b/packages/next-observability/src/analytics/context.tsx new file mode 100644 index 0000000..596cf7a --- /dev/null +++ b/packages/next-observability/src/analytics/context.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { createContext, useContext, ReactNode, useMemo } from "react"; +import type { AnalyticsService } from "@mintel/observability"; +import { + NoopAnalyticsService, + UmamiAnalyticsService, +} from "@mintel/observability"; + +const AnalyticsContext = createContext( + new NoopAnalyticsService(), +); + +export interface AnalyticsContextProviderProps { + service?: AnalyticsService; + config?: { + enabled: boolean; + websiteId?: string; + apiEndpoint: string; + }; + children: ReactNode; +} + +export function AnalyticsContextProvider({ + service, + config, + children, +}: AnalyticsContextProviderProps) { + const activeService = useMemo(() => { + if (service) return service; + if (config) return new UmamiAnalyticsService(config); + return new NoopAnalyticsService(); + }, [service, config]); + + return ( + + {children} + + ); +} + +export function useAnalytics() { + const context = useContext(AnalyticsContext); + if (!context) { + throw new Error( + "useAnalytics must be used within an AnalyticsContextProvider", + ); + } + return context; +} diff --git a/packages/next-observability/src/client.ts b/packages/next-observability/src/client.ts new file mode 100644 index 0000000..19e12cc --- /dev/null +++ b/packages/next-observability/src/client.ts @@ -0,0 +1,4 @@ +"use client"; + +export * from "./analytics/context"; +export * from "./analytics/auto-tracker"; diff --git a/packages/next-observability/src/errors/sentry.ts b/packages/next-observability/src/errors/sentry.ts new file mode 100644 index 0000000..5a988a8 --- /dev/null +++ b/packages/next-observability/src/errors/sentry.ts @@ -0,0 +1,26 @@ +import * as Sentry from "@sentry/nextjs"; + +export interface SentryConfig { + dsn?: string; + enabled?: boolean; + tracesSampleRate?: number; + tunnel?: string; + replaysOnErrorSampleRate?: number; + replaysSessionSampleRate?: number; +} + +/** + * Standardized Sentry initialization for Mintel projects. + */ +export function initSentry(config: SentryConfig) { + Sentry.init({ + dsn: config.dsn, + enabled: config.enabled ?? Boolean(config.dsn || config.tunnel), + tracesSampleRate: config.tracesSampleRate ?? 0, + tunnel: config.tunnel, + replaysOnErrorSampleRate: config.replaysOnErrorSampleRate ?? 1.0, + replaysSessionSampleRate: config.replaysSessionSampleRate ?? 0.1, + }); +} + +export { Sentry }; diff --git a/packages/next-observability/src/handlers/index.ts b/packages/next-observability/src/handlers/index.ts new file mode 100644 index 0000000..b682726 --- /dev/null +++ b/packages/next-observability/src/handlers/index.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; + +/** + * Logic for Umami Smart Proxy Route Handler. + */ +export function createUmamiProxyHandler(config: { + websiteId?: string; + apiEndpoint: string; +}) { + return async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { type, payload } = body; + + if (!config.websiteId) { + return NextResponse.json({ status: "ignored" }, { status: 200 }); + } + + const enhancedPayload = { + ...payload, + website: config.websiteId, + }; + + const response = await fetch(`${config.apiEndpoint}/api/send`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": request.headers.get("user-agent") || "Mintel-Proxy", + "X-Forwarded-For": request.headers.get("x-forwarded-for") || "", + }, + body: JSON.stringify({ type, payload: enhancedPayload }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return new NextResponse(errorText, { status: response.status }); + } + + return NextResponse.json({ status: "ok" }); + } catch (error) { + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } + }; +} + +/** + * Logic for Sentry/GlitchTip Relay Route Handler. + */ +export function createSentryRelayHandler(config: { dsn?: string }) { + return async function POST(request: NextRequest) { + try { + const envelope = await request.text(); + const lines = envelope.split("\n"); + if (lines.length < 1) { + return NextResponse.json({ error: "Empty envelope" }, { status: 400 }); + } + + if (!config.dsn) { + return NextResponse.json({ status: "ignored" }, { status: 200 }); + } + + const dsnUrl = new URL(config.dsn); + const projectId = dsnUrl.pathname.replace("/", ""); + const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`; + + const response = await fetch(relayUrl, { + method: "POST", + body: envelope, + headers: { + "Content-Type": "application/x-sentry-envelope", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return new NextResponse(errorText, { status: response.status }); + } + + return NextResponse.json({ status: "ok" }); + } catch (error) { + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } + }; +} diff --git a/packages/next-observability/src/index.ts b/packages/next-observability/src/index.ts new file mode 100644 index 0000000..ee31d50 --- /dev/null +++ b/packages/next-observability/src/index.ts @@ -0,0 +1,2 @@ +export * from "./handlers/index"; +export * from "./errors/sentry"; diff --git a/packages/next-observability/tsconfig.json b/packages/next-observability/tsconfig.json new file mode 100644 index 0000000..4d48a69 --- /dev/null +++ b/packages/next-observability/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "jsx": "react-jsx", + "esModuleInterop": true + }, + "include": ["src"] +} diff --git a/packages/next-utils/src/env.ts b/packages/next-utils/src/env.ts index af63616..4830251 100644 --- a/packages/next-utils/src/env.ts +++ b/packages/next-utils/src/env.ts @@ -1,20 +1,36 @@ -import { z } from 'zod'; +import { z } from "zod"; export const mintelEnvSchema = { - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), NEXT_PUBLIC_BASE_URL: z.string().url(), - NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(), - NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'), + + // Analytics (Proxy Pattern) + UMAMI_WEBSITE_ID: z.string().optional(), + UMAMI_API_ENDPOINT: z + .string() + .url() + .default("https://analytics.infra.mintel.me"), + + // Error Tracking SENTRY_DSN: z.string().optional(), - LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), + + // Notifications + GOTIFY_URL: z.string().url().optional(), + GOTIFY_TOKEN: z.string().optional(), + + LOG_LEVEL: z + .enum(["trace", "debug", "info", "warn", "error", "fatal"]) + .default("info"), MAIL_HOST: z.string().optional(), MAIL_PORT: z.coerce.number().default(587), MAIL_USERNAME: z.string().optional(), MAIL_PASSWORD: z.string().optional(), MAIL_FROM: z.string().optional(), MAIL_RECIPIENTS: z.preprocess( - (val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val), - z.array(z.string()).default([]) + (val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val), + z.array(z.string()).default([]), ), }; @@ -24,11 +40,26 @@ export function validateMintelEnv(schemaExtension = {}) { ...schemaExtension, }); + const isBuildTime = + process.env.NEXT_PHASE === "phase-production-build" || + process.env.SKIP_ENV_VALIDATION === "true"; + const result = fullSchema.safeParse(process.env); if (!result.success) { - console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors); - throw new Error('Invalid environment variables'); + if (isBuildTime) { + console.warn( + "⚠️ Some environment variables are missing during build, but skipping strict validation.", + ); + // Return partial data to allow build to continue + return process.env as unknown as z.infer; + } + + console.error( + "❌ Invalid environment variables:", + result.error.flatten().fieldErrors, + ); + throw new Error("Invalid environment variables"); } return result.data; diff --git a/packages/observability/README.md b/packages/observability/README.md new file mode 100644 index 0000000..a293c2b --- /dev/null +++ b/packages/observability/README.md @@ -0,0 +1,123 @@ +# @mintel/observability + +Standardized observability package for the Mintel ecosystem, providing Umami analytics and Sentry/GlitchTip error tracking with a focus on privacy and ad-blocker resilience. + +## Features + +- **Umami Smart Proxy**: Track analytics without external scripts and hide your Website ID. +- **Sentry Relay**: Bypass ad-blockers for error tracking by relaying envelopes through your own server. +- **Unified API**: consistent interface for tracking across multiple projects. + +## Installation + +```bash +pnpm add @mintel/observability @sentry/nextjs +``` + +## Usage + +### 1. Unified Environment (via @mintel/next-utils) + +Define the following environment variables: + +```bash +# Analytics +UMAMI_WEBSITE_ID=your-website-id +UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me + +# Error Tracking +SENTRY_DSN=your-sentry-dsn +``` + +Note: No `NEXT_PUBLIC_` prefix is required for these anymore, as they are handled by server-side proxies. + +### 2. Analytics Setup + +In your root layout: + +```tsx +import { + AnalyticsContextProvider, + AnalyticsAutoTracker, + UmamiAnalyticsService, +} from "@mintel/observability"; + +const analytics = new UmamiAnalyticsService({ + enabled: true, + websiteId: process.env.UMAMI_WEBSITE_ID, // Server-side + apiEndpoint: + typeof window === "undefined" ? process.env.UMAMI_API_ENDPOINT : "/stats", +}); + +export default function Layout({ children }) { + return ( + + + {children} + + ); +} +``` + +### 3. Route Handlers + +Create a proxy for Umami: +`app/stats/api/send/route.ts` + +```ts +import { createUmamiProxyHandler } from "@mintel/observability"; +export const POST = await createUmamiProxyHandler({ + websiteId: process.env.UMAMI_WEBSITE_ID, + apiEndpoint: process.env.UMAMI_API_ENDPOINT, +}); +``` + +Create a relay for Sentry: +`app/errors/api/relay/route.ts` + +```ts +import { createSentryRelayHandler } from "@mintel/observability"; +export const POST = await createSentryRelayHandler({ + dsn: process.env.SENTRY_DSN, +}); +``` + +### 4. Notification Setup (Server-side) + +```ts +import { GotifyNotificationService } from "@mintel/observability"; + +const notifications = new GotifyNotificationService({ + enabled: true, + url: process.env.GOTIFY_URL, + token: process.env.GOTIFY_TOKEN, +}); + +await notifications.notify({ + title: "Lead Capture", + message: "New contact form submission", + priority: 5, +}); +``` + +### 5. Sentry Configuration + +Use `initSentry` in your `sentry.server.config.ts` and `sentry.client.config.ts`. + +On the client, use the tunnel: + +```ts +initSentry({ + dsn: "https://public@errors.infra.mintel.me/1", // Placeholder + tunnel: "/errors/api/relay", +}); +``` + +## Architecture + +This package implements the **Smart Proxy** pattern: + +- The client NEVER knows the real `UMAMI_WEBSITE_ID`. +- Tracking events are sent to your own domain (`/stats/api/send`). +- Your server injects the secret ID and forwards to Umami. +- This bypasses ad-blockers and keeps your configuration secure. diff --git a/packages/observability/package.json b/packages/observability/package.json new file mode 100644 index 0000000..2a65733 --- /dev/null +++ b/packages/observability/package.json @@ -0,0 +1,34 @@ +{ + "name": "@mintel/observability", + "version": "1.0.0", + "publishConfig": { + "access": "public", + "registry": "https://npm.infra.mintel.me" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "type": "module", + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "dev": "tsup src/index.ts --format cjs,esm --watch --dts", + "lint": "eslint src/", + "test": "vitest run" + }, + "dependencies": {}, + "devDependencies": { + "@mintel/eslint-config": "workspace:*", + "@mintel/tsconfig": "workspace:*", + "eslint": "^9.39.2", + "tsup": "^8.0.0", + "typescript": "^5.0.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/observability/src/analytics/noop.test.ts b/packages/observability/src/analytics/noop.test.ts new file mode 100644 index 0000000..f2feedb --- /dev/null +++ b/packages/observability/src/analytics/noop.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { NoopAnalyticsService } from "./noop"; + +describe("NoopAnalyticsService", () => { + it("should not throw on track", () => { + const service = new NoopAnalyticsService(); + expect(() => service.track("test")).not.toThrow(); + }); + + it("should not throw on trackPageview", () => { + const service = new NoopAnalyticsService(); + expect(() => service.trackPageview()).not.toThrow(); + }); +}); diff --git a/packages/observability/src/analytics/noop.ts b/packages/observability/src/analytics/noop.ts new file mode 100644 index 0000000..f2e77c0 --- /dev/null +++ b/packages/observability/src/analytics/noop.ts @@ -0,0 +1,15 @@ +import type { AnalyticsService, AnalyticsEventProperties } from "./service"; + +/** + * No-operation analytics service. + * Used when analytics are disabled or for local development. + */ +export class NoopAnalyticsService implements AnalyticsService { + track(eventName: string, props?: AnalyticsEventProperties): void { + // Do nothing + } + + trackPageview(url?: string): void { + // Do nothing + } +} diff --git a/packages/observability/src/analytics/service.ts b/packages/observability/src/analytics/service.ts new file mode 100644 index 0000000..6e9dc5d --- /dev/null +++ b/packages/observability/src/analytics/service.ts @@ -0,0 +1,31 @@ +/** + * Type definition for analytics event properties. + */ +export type AnalyticsEventProperties = Record< + string, + string | number | boolean | null | undefined +>; + +/** + * Interface for analytics service implementations. + * + * This interface defines the contract for all analytics services, + * allowing for different implementations (Umami, Google Analytics, etc.) + * while maintaining a consistent API. + */ +export interface AnalyticsService { + /** + * Track a custom event with optional properties. + * + * @param eventName - The name of the event to track + * @param props - Optional event properties (metadata) + */ + track(eventName: string, props?: AnalyticsEventProperties): void; + + /** + * Track a pageview. + * + * @param url - The URL to track (defaults to current location) + */ + trackPageview(url?: string): void; +} diff --git a/packages/observability/src/analytics/umami.test.ts b/packages/observability/src/analytics/umami.test.ts new file mode 100644 index 0000000..e1ab057 --- /dev/null +++ b/packages/observability/src/analytics/umami.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { UmamiAnalyticsService } from "./umami"; + +describe("UmamiAnalyticsService", () => { + const mockConfig = { + websiteId: "test-website-id", + apiEndpoint: "https://analytics.test", + enabled: true, + }; + + const mockLogger = { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + it("should not send payload if disabled", async () => { + const service = new UmamiAnalyticsService({ + ...mockConfig, + enabled: false, + }); + service.track("test-event"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should send payload with correct data for track", async () => { + const service = new UmamiAnalyticsService(mockConfig, mockLogger); + + (global.fetch as any).mockResolvedValue({ ok: true }); + + service.track("test-event", { foo: "bar" }); + + // Wait for async sendPayload + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(global.fetch).toHaveBeenCalledWith( + "https://analytics.test/api/send", + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"type":"event"'), + }), + ); + + const callBody = JSON.parse((global.fetch as any).mock.calls[0][1].body); + expect(callBody.payload.name).toBe("test-event"); + expect(callBody.payload.data.foo).toBe("bar"); + expect(callBody.payload.website).toBe("test-website-id"); + }); + + it("should log warning if send fails", async () => { + const service = new UmamiAnalyticsService(mockConfig, mockLogger); + + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal error"), + }); + + service.track("test-event"); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockLogger.warn).toHaveBeenCalledWith( + "Umami API responded with error", + expect.objectContaining({ status: 500 }), + ); + }); +}); diff --git a/packages/observability/src/analytics/umami.ts b/packages/observability/src/analytics/umami.ts new file mode 100644 index 0000000..f140fea --- /dev/null +++ b/packages/observability/src/analytics/umami.ts @@ -0,0 +1,115 @@ +import type { AnalyticsService, AnalyticsEventProperties } from "./service"; + +export interface UmamiConfig { + websiteId?: string; + apiEndpoint: string; // The endpoint to send to (proxied or direct) + enabled: boolean; +} + +export interface Logger { + debug(msg: string, data?: any): void; + warn(msg: string, data?: any): void; + error(msg: string, data?: any): void; + trace(msg: string, data?: any): void; +} + +/** + * Umami Analytics Service Implementation (Script-less/Proxy edition). + */ +export class UmamiAnalyticsService implements AnalyticsService { + private logger?: Logger; + + constructor( + private config: UmamiConfig, + logger?: Logger, + ) { + this.logger = logger; + } + + private async sendPayload(type: "event", data: Record) { + if (!this.config.enabled) return; + + const isClient = typeof window !== "undefined"; + const websiteId = this.config.websiteId; + + if (!isClient && !websiteId) { + this.logger?.warn( + "Umami tracking called on server but no Website ID configured", + ); + return; + } + + try { + const payload = { + website: websiteId, + hostname: isClient ? window.location.hostname : "server", + screen: isClient + ? `${window.screen.width}x${window.screen.height}` + : undefined, + language: isClient ? navigator.language : undefined, + referrer: isClient ? document.referrer : undefined, + ...data, + }; + + this.logger?.trace("Sending analytics payload", { type, url: data.url }); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(`${this.config.apiEndpoint}/api/send`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": isClient ? navigator.userAgent : "Mintel-Server", + }, + body: JSON.stringify({ type, payload }), + keepalive: true, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + this.logger?.warn("Umami API responded with error", { + status: response.status, + error: errorText.slice(0, 100), + }); + } + } catch (fetchError) { + clearTimeout(timeoutId); + if ((fetchError as Error).name === "AbortError") { + this.logger?.error("Umami request timed out"); + } else { + throw fetchError; + } + } + } catch (error) { + this.logger?.error("Failed to send analytics", { + error: (error as Error).message, + }); + } + } + + track(eventName: string, props?: AnalyticsEventProperties) { + this.sendPayload("event", { + name: eventName, + data: props, + url: + typeof window !== "undefined" + ? window.location.pathname + window.location.search + : undefined, + }); + } + + trackPageview(url?: string) { + this.sendPayload("event", { + url: + url || + (typeof window !== "undefined" + ? window.location.pathname + window.location.search + : undefined), + }); + } +} diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts new file mode 100644 index 0000000..cd62df6 --- /dev/null +++ b/packages/observability/src/index.ts @@ -0,0 +1,9 @@ +// Analytics +export * from "./analytics/service"; +export * from "./analytics/umami"; +export * from "./analytics/noop"; + +// Notifications +export * from "./notifications/service"; +export * from "./notifications/gotify"; +export * from "./notifications/noop"; diff --git a/packages/observability/src/notifications/gotify.test.ts b/packages/observability/src/notifications/gotify.test.ts new file mode 100644 index 0000000..d869a3d --- /dev/null +++ b/packages/observability/src/notifications/gotify.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GotifyNotificationService } from "./gotify"; + +describe("GotifyNotificationService", () => { + const mockConfig = { + url: "https://gotify.test", + token: "test-token", + enabled: true, + }; + + const mockLogger = { + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + it("should not notify if disabled", async () => { + const service = new GotifyNotificationService({ + ...mockConfig, + enabled: false, + }); + await service.notify({ title: "test", message: "test" }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should send correct payload to Gotify", async () => { + const service = new GotifyNotificationService(mockConfig, mockLogger); + (global.fetch as any).mockResolvedValue({ ok: true }); + + await service.notify({ + title: "Alert", + message: "Critical issue", + priority: 8, + }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://gotify.test/message?token=test-token", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Alert", + message: "Critical issue", + priority: 8, + }), + }), + ); + }); + + it("should handle missing trailing slash in URL", async () => { + const service = new GotifyNotificationService({ + ...mockConfig, + url: "https://gotify.test", + }); + (global.fetch as any).mockResolvedValue({ ok: true }); + + await service.notify({ title: "test", message: "test" }); + + expect((global.fetch as any).mock.calls[0][0]).toBe( + "https://gotify.test/message?token=test-token", + ); + }); + + it("should log error if notify fails", async () => { + const service = new GotifyNotificationService(mockConfig, mockLogger); + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 401, + text: () => Promise.resolve("Unauthorized"), + }); + + await service.notify({ title: "test", message: "test" }); + + expect(mockLogger.error).toHaveBeenCalledWith( + "Gotify notification failed", + expect.objectContaining({ status: 401 }), + ); + }); +}); diff --git a/packages/observability/src/notifications/gotify.ts b/packages/observability/src/notifications/gotify.ts new file mode 100644 index 0000000..387b16d --- /dev/null +++ b/packages/observability/src/notifications/gotify.ts @@ -0,0 +1,56 @@ +import { NotificationOptions, NotificationService } from "./service"; + +export interface GotifyConfig { + url: string; + token: string; + enabled: boolean; +} + +/** + * Gotify Notification Service implementation. + */ +export class GotifyNotificationService implements NotificationService { + constructor( + private config: GotifyConfig, + private logger?: { error(msg: string, data?: any): void }, + ) {} + + async notify(options: NotificationOptions): Promise { + if (!this.config.enabled) return; + + try { + const { title, message, priority = 4 } = options; + + // Ensure we have a trailing slash for base URL, then append 'message' + const baseUrl = this.config.url.endsWith("/") + ? this.config.url + : `${this.config.url}/`; + const url = new URL("message", baseUrl); + url.searchParams.set("token", this.config.token); + + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title, + message, + priority, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + this.logger?.error("Gotify notification failed", { + status: response.status, + error: errorText.slice(0, 100), + }); + } + } catch (error) { + this.logger?.error("Gotify notification error", { + error: (error as Error).message, + }); + } + } +} diff --git a/packages/observability/src/notifications/noop.test.ts b/packages/observability/src/notifications/noop.test.ts new file mode 100644 index 0000000..486c625 --- /dev/null +++ b/packages/observability/src/notifications/noop.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from "vitest"; +import { NoopNotificationService } from "./noop"; + +describe("NoopNotificationService", () => { + it("should not throw on notify", async () => { + const service = new NoopNotificationService(); + await expect( + service.notify({ title: "test", message: "test" }), + ).resolves.not.toThrow(); + }); +}); diff --git a/packages/observability/src/notifications/noop.ts b/packages/observability/src/notifications/noop.ts new file mode 100644 index 0000000..8040f56 --- /dev/null +++ b/packages/observability/src/notifications/noop.ts @@ -0,0 +1,10 @@ +import { NotificationService } from "./service"; + +/** + * No-operation notification service. + */ +export class NoopNotificationService implements NotificationService { + async notify(): Promise { + // Do nothing + } +} diff --git a/packages/observability/src/notifications/service.ts b/packages/observability/src/notifications/service.ts new file mode 100644 index 0000000..2c3a883 --- /dev/null +++ b/packages/observability/src/notifications/service.ts @@ -0,0 +1,16 @@ +export interface NotificationOptions { + title: string; + message: string; + priority?: number; +} + +/** + * Interface for notification service implementations. + * Allows for different implementations (Gotify, Slack, Email, etc.) + */ +export interface NotificationService { + /** + * Send a notification. + */ + notify(options: NotificationOptions): Promise; +} diff --git a/packages/observability/tsconfig.json b/packages/observability/tsconfig.json new file mode 100644 index 0000000..d55c842 --- /dev/null +++ b/packages/observability/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "jsx": "react-jsx", + "esModuleInterop": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62bb93c..c0bdc66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,9 +80,18 @@ importers: apps/sample-website: dependencies: + '@mintel/next-observability': + specifier: workspace:* + version: link:../../packages/next-observability '@mintel/next-utils': specifier: workspace:* version: link:../../packages/next-utils + '@mintel/observability': + specifier: workspace:* + version: link:../../packages/observability + '@sentry/nextjs': + specifier: ^8.55.0 + version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1) next: specifier: 15.1.6 version: 15.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -289,6 +298,46 @@ importers: specifier: ^4.8.2 version: 4.8.2(next@15.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + packages/next-observability: + dependencies: + '@mintel/observability': + specifier: workspace:* + version: link:../observability + '@sentry/nextjs': + specifier: ^8.55.0 + version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1(@swc/core@1.15.11)) + next: + specifier: 15.1.6 + version: 15.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + devDependencies: + '@mintel/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@mintel/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/react': + specifier: ^19.0.0 + version: 19.2.10 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.10) + eslint: + specifier: ^9.39.2 + version: 9.39.2(jiti@2.6.1) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + tsup: + specifier: ^8.0.0 + version: 8.5.1(@swc/core@1.15.11)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages/next-utils: dependencies: '@directus/sdk': @@ -320,6 +369,27 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/observability: + devDependencies: + '@mintel/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@mintel/tsconfig': + specifier: workspace:* + version: link:../tsconfig + eslint: + specifier: ^9.39.2 + version: 9.39.2(jiti@2.6.1) + tsup: + specifier: ^8.0.0 + version: 8.5.1(@swc/core@1.15.11)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@20.19.30)(happy-dom@20.4.0)(jsdom@27.4.0)(terser@5.46.0) + packages/tsconfig: {} packages: @@ -598,102 +668,204 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} @@ -706,6 +878,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} @@ -718,6 +896,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} @@ -730,24 +914,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} @@ -2109,12 +2317,26 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -2137,30 +2359,45 @@ packages: vite: optional: true + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -2824,6 +3061,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -3906,6 +4148,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4510,6 +4755,10 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -4518,6 +4767,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -4668,11 +4921,47 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4713,6 +5002,31 @@ packages: yaml: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -5349,81 +5663,150 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.27.2': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.27.2': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.27.2': optional: true '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.27.2': optional: true @@ -6355,6 +6738,33 @@ snapshots: '@sentry/core@8.55.0': {} + '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1(@swc/core@1.15.11))': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.39.0 + '@rollup/plugin-commonjs': 28.0.1(rollup@3.29.5) + '@sentry-internal/browser-utils': 8.55.0 + '@sentry/core': 8.55.0 + '@sentry/node': 8.55.0 + '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0) + '@sentry/react': 8.55.0(react@19.2.4) + '@sentry/vercel-edge': 8.55.0 + '@sentry/webpack-plugin': 2.22.7(webpack@5.104.1(@swc/core@1.15.11)) + chalk: 3.0.0 + next: 15.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + resolve: 1.22.8 + rollup: 3.29.5 + stacktrace-parser: 0.1.11 + transitivePeerDependencies: + - '@opentelemetry/context-async-hooks' + - '@opentelemetry/core' + - '@opentelemetry/instrumentation' + - '@opentelemetry/sdk-trace-base' + - encoding + - react + - supports-color + - webpack + '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)': dependencies: '@opentelemetry/api': 1.9.0 @@ -6444,6 +6854,16 @@ snapshots: '@opentelemetry/api': 1.9.0 '@sentry/core': 8.55.0 + '@sentry/webpack-plugin@2.22.7(webpack@5.104.1(@swc/core@1.15.11))': + dependencies: + '@sentry/bundler-plugin-core': 2.22.7 + unplugin: 1.0.1 + uuid: 9.0.1 + webpack: 5.104.1(@swc/core@1.15.11) + transitivePeerDependencies: + - encoding + - supports-color + '@sentry/webpack-plugin@2.22.7(webpack@5.104.1)': dependencies: '@sentry/bundler-plugin-core': 2.22.7 @@ -6818,6 +7238,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -6835,6 +7262,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.30)(terser@5.46.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.30)(terser@5.46.0) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@20.19.30)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -6851,6 +7286,10 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.30)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -6859,6 +7298,11 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 @@ -6870,6 +7314,12 @@ snapshots: '@vitest/utils': 4.0.18 pathe: 2.0.3 + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -6882,12 +7332,22 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 '@vitest/spy@4.0.18': {} + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -7632,6 +8092,32 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -8832,6 +9318,8 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -9467,6 +9955,17 @@ snapshots: term-size@2.2.1: {} + terser-webpack-plugin@5.3.16(@swc/core@1.15.11)(webpack@5.104.1(@swc/core@1.15.11)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.46.0 + webpack: 5.104.1(@swc/core@1.15.11) + optionalDependencies: + '@swc/core': 1.15.11 + terser-webpack-plugin@5.3.16(webpack@5.104.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -9504,10 +10003,14 @@ snapshots: tinypool@1.1.1: {} + tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} tinyrainbow@3.0.3: {} + tinyspy@3.0.2: {} + tinyspy@4.0.4: {} tldts-core@7.0.21: {} @@ -9703,6 +10206,24 @@ snapshots: uuid@9.0.1: {} + vite-node@2.1.9(@types/node@20.19.30)(terser@5.46.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@20.19.30)(terser@5.46.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@3.2.4(@types/node@20.19.30)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 @@ -9724,6 +10245,16 @@ snapshots: - tsx - yaml + vite@5.4.21(@types/node@20.19.30)(terser@5.46.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.57.1 + optionalDependencies: + '@types/node': 20.19.30 + fsevents: 2.3.3 + terser: 5.46.0 + vite@7.3.1(@types/node@20.19.30)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -9740,6 +10271,43 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vitest@2.1.9(@types/node@20.19.30)(happy-dom@20.4.0)(jsdom@27.4.0)(terser@5.46.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.30)(terser@5.46.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@20.19.30)(terser@5.46.0) + vite-node: 2.1.9(@types/node@20.19.30)(terser@5.46.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.30 + happy-dom: 20.4.0 + jsdom: 27.4.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@3.2.4(@types/node@20.19.30)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 @@ -9872,6 +10440,38 @@ snapshots: - esbuild - uglify-js + webpack@5.104.1(@swc/core@1.15.11): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.28.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.4 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.16(@swc/core@1.15.11)(webpack@5.104.1(@swc/core@1.15.11)) + watchpack: 2.5.1 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + whatwg-mimetype@3.0.0: {} whatwg-mimetype@4.0.0: {}