From 70397e3c2a7ec4baee7445286b2eb7333e33f2ae Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Fri, 26 Sep 2025 12:07:08 -0400 Subject: [PATCH] Add screenshot generation and integrate into domain report view (#32) --- .env.example | 4 +- app/api/cron/blob-prune/route.test.ts | 50 ++ app/api/cron/blob-prune/route.ts | 85 +++ components/domain/domain-report-view.tsx | 35 +- components/domain/screenshot-tooltip.test.tsx | 93 +++ components/domain/screenshot-tooltip.tsx | 45 ++ components/domain/screenshot.test.tsx | 93 +++ components/domain/screenshot.tsx | 88 +++ components/ui/tooltip.tsx | 9 +- lib/blob.test.ts | 106 ++- lib/blob.ts | 126 +++- lib/image.ts | 94 +++ next.config.ts | 2 + package.json | 3 + pnpm-lock.yaml | 663 ++++++++++++++++++ pnpm-workspace.yaml | 1 + server/routers/domain.ts | 5 + server/services/constants.ts | 2 + server/services/doh-providers.ts | 3 +- server/services/favicon.ts | 69 +- server/services/screenshot.test.ts | 61 ++ server/services/screenshot.ts | 230 ++++++ vercel.json | 11 +- 23 files changed, 1770 insertions(+), 108 deletions(-) create mode 100644 app/api/cron/blob-prune/route.test.ts create mode 100644 app/api/cron/blob-prune/route.ts create mode 100644 components/domain/screenshot-tooltip.test.tsx create mode 100644 components/domain/screenshot-tooltip.tsx create mode 100644 components/domain/screenshot.test.tsx create mode 100644 components/domain/screenshot.tsx create mode 100644 lib/image.ts create mode 100644 server/services/constants.ts create mode 100644 server/services/screenshot.test.ts create mode 100644 server/services/screenshot.ts diff --git a/.env.example b/.env.example index 73ab110..e83e6ab 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,8 @@ KV_REST_API_URL= # Vercel Blob (add integration on Vercel; token for local/dev) BLOB_READ_WRITE_TOKEN= -# Secret used to derive unpredictable blob paths for favicons -FAVICON_BLOB_SIGNING_SECRET= +# Secret used to derive unpredictable blob paths for generated images (favicons and screenshots) +BLOB_SIGNING_SECRET= # Mapbox access token for react-map-gl NEXT_PUBLIC_MAPBOX_TOKEN= diff --git a/app/api/cron/blob-prune/route.test.ts b/app/api/cron/blob-prune/route.test.ts new file mode 100644 index 0000000..32e4d5f --- /dev/null +++ b/app/api/cron/blob-prune/route.test.ts @@ -0,0 +1,50 @@ +/* @vitest-environment node */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@vercel/blob", () => ({ + list: vi.fn(async (_opts: unknown) => ({ + blobs: [ + { pathname: "favicons/1/abc/32.png", url: "https://blob/f1" }, + { pathname: "favicons/999999/def/32.png", url: "https://blob/f2" }, + { pathname: "screenshots/1/ghi/1200x630.png", url: "https://blob/s1" }, + ], + cursor: undefined, + })), + del: vi.fn(async (_url: string) => undefined), +})); + +import { GET } from "./route"; + +describe("/api/cron/blob-prune", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("requires secret and prunes old buckets (GET)", async () => { + process.env.CRON_SECRET = "test-secret"; + // Force nowBucket to be large so bucket=1 items are considered old + const realNow = Date.now; + Date.now = () => 10_000_000_000_000; + + const req = new Request("http://localhost/api/cron/blob-prune", { + method: "GET", + headers: { authorization: "Bearer test-secret" }, + }); + const res = await GET(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.deletedCount).toBeGreaterThan(0); + + // restore + Date.now = realNow; + }); + + it("rejects when secret missing or invalid (GET)", async () => { + delete process.env.CRON_SECRET; + const req = new Request("http://localhost/api/cron/blob-prune", { + method: "GET", + }); + const res = await GET(req); + expect(res.status).toBe(401); + }); +}); diff --git a/app/api/cron/blob-prune/route.ts b/app/api/cron/blob-prune/route.ts new file mode 100644 index 0000000..108f4e8 --- /dev/null +++ b/app/api/cron/blob-prune/route.ts @@ -0,0 +1,85 @@ +import { del, list } from "@vercel/blob"; +import { NextResponse } from "next/server"; +import { getFaviconBucket, getScreenshotBucket } from "@/lib/blob"; + +export const runtime = "nodejs"; + +function getCronSecret(): string | null { + return process.env.CRON_SECRET || null; +} + +function parseIntEnv(name: string, fallback: number): number { + const v = Number(process.env[name]); + return Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback; +} + +// Keep last N buckets for safety +const KEEP_BUCKETS = () => parseIntEnv("BLOB_KEEP_BUCKETS", 2); + +/** + * We prune old time-bucketed assets under `favicons/` and `screenshots/`. + * Paths are structured as: `${kind}/${bucket}/${digest}/...`. + */ +function shouldDeletePath( + pathname: string, + currentBucket: number, + keep: number, +) { + // Expect: kind/bucket/... + const parts = pathname.split("/"); + if (parts.length < 3) return false; + const bucketNum = Number(parts[1]); + if (!Number.isFinite(bucketNum)) return false; + return bucketNum <= currentBucket - keep; +} + +export async function GET(req: Request) { + const secret = getCronSecret(); + const header = req.headers.get("authorization"); + if (!secret || header !== `Bearer ${secret}`) { + return new NextResponse("unauthorized", { status: 401 }); + } + + const keep = KEEP_BUCKETS(); + // Compute current bucket per kind using the same helpers as blob paths + const faviconBucket = getFaviconBucket(); + const screenshotBucket = getScreenshotBucket(); + + const deleted: string[] = []; + const errors: Array<{ path: string; error: string }> = []; + + // List favicons and screenshots prefixes separately to reduce listing size + for (const prefix of ["favicons/", "screenshots/"]) { + // Paginate list in case of many objects + let cursor: string | undefined; + do { + const res = await list({ + prefix, + cursor, + token: process.env.BLOB_READ_WRITE_TOKEN, + }); + cursor = res.cursor || undefined; + for (const item of res.blobs) { + const current = prefix.startsWith("favicons/") + ? faviconBucket + : screenshotBucket; + if (shouldDeletePath(item.pathname, current, keep)) { + try { + await del(item.url, { token: process.env.BLOB_READ_WRITE_TOKEN }); + deleted.push(item.pathname); + } catch (err) { + errors.push({ + path: item.pathname, + error: (err as Error)?.message || "unknown", + }); + } + } + } + } while (cursor); + } + + return NextResponse.json({ + deletedCount: deleted.length, + errorsCount: errors.length, + }); +} diff --git a/components/domain/domain-report-view.tsx b/components/domain/domain-report-view.tsx index 6be3147..73306d9 100644 --- a/components/domain/domain-report-view.tsx +++ b/components/domain/domain-report-view.tsx @@ -13,6 +13,7 @@ import { DomainLoadingState } from "./domain-loading-state"; import { DomainUnregisteredState } from "./domain-unregistered-state"; import { exportDomainData } from "./export-data"; import { Favicon } from "./favicon"; +import { ScreenshotTooltip } from "./screenshot-tooltip"; import { CertificatesSection } from "./sections/certificates-section"; import { DnsRecordsSection } from "./sections/dns-records-section"; import { HeadersSection } from "./sections/headers-section"; @@ -85,22 +86,24 @@ export function DomainReportView({
- - captureClient("external_domain_link_clicked", { domain }) - } - > - -

{domain}

-
+ ), + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("next/image", () => ({ + __esModule: true, + default: ({ alt, src }: { alt: string; src: string }) => ( + // biome-ignore lint/performance/noImgElement: just a test + {alt} + ), +})); + +vi.mock("@/lib/trpc/client", () => ({ + useTRPC: () => ({ + domain: { + screenshot: { + queryOptions: (vars: unknown) => ({ queryKey: ["screenshot", vars] }), + }, + }, + }), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQuery: vi.fn(), + }; +}); + +const { useQuery } = await import("@tanstack/react-query"); + +describe("ScreenshotTooltip", () => { + beforeEach(() => { + (useQuery as unknown as Mock).mockReset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("fetches on open and shows loading UI", () => { + (useQuery as unknown as Mock).mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: false, + }); + render( + + hover me + , + ); + // Simulate open by clicking the trigger + fireEvent.click(screen.getByText("hover me")); + expect(screen.getByText(/loading screenshot/i)).toBeInTheDocument(); + }); + + it("renders image when loaded", () => { + (useQuery as unknown as Mock).mockReturnValue({ + data: { url: "https://blob/url.png" }, + isLoading: false, + isFetching: false, + }); + render( + + hover me + , + ); + fireEvent.click(screen.getByText("hover me")); + const img = screen.getByRole("img", { + name: /homepage preview of example.com/i, + }); + expect(img).toHaveAttribute("src", "https://blob/url.png"); + }); +}); diff --git a/components/domain/screenshot-tooltip.tsx b/components/domain/screenshot-tooltip.tsx new file mode 100644 index 0000000..8387722 --- /dev/null +++ b/components/domain/screenshot-tooltip.tsx @@ -0,0 +1,45 @@ +"use client"; + +import * as React from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Screenshot } from "./screenshot"; + +export function ScreenshotTooltip({ + domain, + children, +}: { + domain: string; + children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(false); + const [hasOpened, setHasOpened] = React.useState(false); + + return ( + { + setOpen(v); + if (v) setHasOpened(true); + }} + > + {children} + +
+ +
+
+
+ ); +} diff --git a/components/domain/screenshot.test.tsx b/components/domain/screenshot.test.tsx new file mode 100644 index 0000000..509e5ae --- /dev/null +++ b/components/domain/screenshot.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import type { Mock } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Screenshot } from "./screenshot"; + +vi.mock("next/image", () => ({ + __esModule: true, + default: ({ + alt, + src, + width, + height, + }: { + alt: string; + src: string; + width: number; + height: number; + }) => + React.createElement("img", { + alt, + src, + width, + height, + "data-slot": "image", + }), +})); + +vi.mock("@/lib/trpc/client", () => ({ + useTRPC: () => ({ + domain: { + screenshot: { + queryOptions: (vars: unknown) => ({ queryKey: ["screenshot", vars] }), + }, + }, + }), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQuery: vi.fn(), + }; +}); + +const { useQuery } = await import("@tanstack/react-query"); + +describe("Screenshot", () => { + beforeEach(() => { + (useQuery as unknown as Mock).mockReset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows loading UI during fetch", () => { + (useQuery as unknown as Mock).mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: false, + }); + render(); + expect(screen.getByText(/loading screenshot/i)).toBeInTheDocument(); + }); + + it("renders image when url present", () => { + (useQuery as unknown as Mock).mockReturnValue({ + data: { url: "https://blob/url.png" }, + isLoading: false, + isFetching: false, + }); + render(); + const img = screen.getByRole("img", { + name: /homepage preview of example.com/i, + }); + expect(img).toHaveAttribute("src", "https://blob/url.png"); + }); + + it("shows fallback when no url and not loading", () => { + (useQuery as unknown as Mock).mockReturnValue({ + data: { url: null }, + isLoading: false, + isFetching: false, + }); + render(); + expect( + screen.getByText(/unable to generate a preview/i), + ).toBeInTheDocument(); + }); +}); diff --git a/components/domain/screenshot.tsx b/components/domain/screenshot.tsx new file mode 100644 index 0000000..4aaf4cb --- /dev/null +++ b/components/domain/screenshot.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import Image from "next/image"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; + +export function Screenshot({ + domain, + enabled = true, + className, + caption, + width = 1200, + height = 630, + imageClassName, + aspectClassName = "aspect-[1200/630]", +}: { + domain: string; + enabled?: boolean; + className?: string; + caption?: string; + width?: number; + height?: number; + imageClassName?: string; + aspectClassName?: string; +}) { + const trpc = useTRPC(); + const { data, isLoading, isFetching } = useQuery( + trpc.domain.screenshot.queryOptions( + { domain }, + { + staleTime: 24 * 60 * 60_000, // 24h in ms + enabled, + }, + ), + ); + + const url = data?.url ?? null; + const loading = isLoading || isFetching; + + return ( +
+ {loading && ( +
+
+
+ + Loading screenshot... +
+
+
+ )} + {!loading && url && ( +
+ {`Homepage + {caption ? ( +
+ {caption} +
+ ) : null} +
+ )} + {!loading && !url && ( +
+ Unable to generate a preview. +
+ )} +
+ ); +} diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx index 17caa4e..d3b7674 100644 --- a/components/ui/tooltip.tsx +++ b/components/ui/tooltip.tsx @@ -38,8 +38,11 @@ function TooltipContent({ className, sideOffset = 0, children, + hideArrow, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + hideArrow?: boolean; +}) { return ( {children} - + {hideArrow ? null : ( + + )} ); diff --git a/lib/blob.test.ts b/lib/blob.test.ts index b689123..f62f0bb 100644 --- a/lib/blob.test.ts +++ b/lib/blob.test.ts @@ -10,8 +10,12 @@ vi.mock("@vercel/blob", () => ({ import { computeFaviconBlobPath, + computeScreenshotBlobPath, + getScreenshotBucket, headFaviconBlob, + headScreenshotBlob, putFaviconBlob, + putScreenshotBlob, } from "./blob"; const originalEnv = { ...process.env }; @@ -26,7 +30,7 @@ afterEach(() => { describe("blob utils", () => { it("computeFaviconBlobPath is deterministic and secret-dependent", () => { - process.env.FAVICON_BLOB_SIGNING_SECRET = "secret-a"; + process.env.BLOB_SIGNING_SECRET = "secret-a"; const a1 = computeFaviconBlobPath("example.com", 32); const a2 = computeFaviconBlobPath("example.com", 32); expect(a1).toBe(a2); @@ -34,22 +38,54 @@ describe("blob utils", () => { const a3 = computeFaviconBlobPath("example.com", 64); expect(a3).not.toBe(a1); - process.env.FAVICON_BLOB_SIGNING_SECRET = "secret-b"; + process.env.BLOB_SIGNING_SECRET = "secret-b"; const b1 = computeFaviconBlobPath("example.com", 32); expect(b1).not.toBe(a1); expect(a1).toMatch(/^favicons\//); }); - it("headFaviconBlob returns URL on success and null on error", async () => { + it("computeScreenshotBlobPath is deterministic and secret-dependent", () => { + process.env.BLOB_SIGNING_SECRET = "secret-a"; + const s1 = computeScreenshotBlobPath("example.com", 1200, 630); + const s2 = computeScreenshotBlobPath("example.com", 1200, 630); + expect(s1).toBe(s2); + + process.env.BLOB_SIGNING_SECRET = "secret-b"; + const s3 = computeScreenshotBlobPath("example.com", 1200, 630); + expect(s3).not.toBe(s1); + expect(s1).toMatch(/^screenshots\//); + }); + + it("paths include bucket segments and change when bucket changes", () => { + process.env.FAVICON_TTL_SECONDS = "10"; + process.env.SCREENSHOT_TTL_SECONDS = "10"; + const base = 1_000_000_000_000; + const realNow = Date.now; + + Date.now = () => base; + const f1 = computeFaviconBlobPath("example.com", 32); + const s1 = computeScreenshotBlobPath("example.com", 1200, 630); + + Date.now = () => base + 11_000; + const f2 = computeFaviconBlobPath("example.com", 32); + const s2 = computeScreenshotBlobPath("example.com", 1200, 630); + expect(f1).not.toBe(f2); + expect(s1).not.toBe(s2); + Date.now = realNow; + }); + + it("headFaviconBlob returns URL on success and null when both buckets miss", async () => { const { head } = await import("@vercel/blob"); (head as unknown as import("vitest").Mock).mockResolvedValueOnce({ url: "https://blob/existing.png", }); const url = await headFaviconBlob("example.com", 32); expect(url).toBe("https://blob/existing.png"); - (head as unknown as import("vitest").Mock).mockRejectedValueOnce( - new Error("fail"), + new Error("fail-current"), + ); + (head as unknown as import("vitest").Mock).mockRejectedValueOnce( + new Error("fail-prev"), ); const none = await headFaviconBlob("example.com", 32); expect(none).toBeNull(); @@ -66,4 +102,64 @@ describe("blob utils", () => { contentType: "image/png", }); }); + + it("putScreenshotBlob uploads with expected options and returns URL", async () => { + process.env.SCREENSHOT_TTL_SECONDS = "10"; + const { put } = await import("@vercel/blob"); + (put as unknown as import("vitest").Mock).mockClear(); + const url = await putScreenshotBlob( + "example.com", + 1200, + 630, + Buffer.from([1]), + ); + expect(url).toBe("https://blob/put.png"); + const calls = (put as unknown as import("vitest").Mock).mock.calls; + const call = calls[calls.length - 1]; + expect(call?.[0]).toMatch(/^screenshots\//); + expect(call?.[0]).toMatch(/\/1200x630\.png$/); + expect(call?.[2]).toMatchObject({ + access: "public", + contentType: "image/png", + }); + }); + + it("headScreenshotBlob falls back to previous bucket on miss", async () => { + process.env.SCREENSHOT_TTL_SECONDS = "10"; + const base = 1_000_000_000_000; + const realNow = Date.now; + Date.now = () => base; + void getScreenshotBucket(); + Date.now = () => base + 1_000; + + const { head } = await import("@vercel/blob"); + (head as unknown as import("vitest").Mock).mockRejectedValueOnce( + new Error("current missing"), + ); + (head as unknown as import("vitest").Mock).mockResolvedValueOnce({ + url: "https://blob/fallback.png", + }); + const url = await headScreenshotBlob("example.com", 1200, 630); + expect(url).toBe("https://blob/fallback.png"); + Date.now = realNow; + }); + + it("headScreenshotBlob returns URL on success and null when both buckets miss", async () => { + const { head } = await import("@vercel/blob"); + (head as unknown as import("vitest").Mock).mockResolvedValueOnce({ + url: "https://blob/existing-screenshot.png", + }); + const url = await headScreenshotBlob("example.com", 1200, 630); + expect(url).toBe("https://blob/existing-screenshot.png"); + + // Miss current and previous + (head as unknown as import("vitest").Mock).mockRejectedValueOnce( + new Error("miss-current"), + ); + (head as unknown as import("vitest").Mock).mockRejectedValueOnce( + new Error("miss-prev"), + ); + const none = await headScreenshotBlob("example.com", 1200, 630); + expect(none).toBeNull(); + }); }); diff --git a/lib/blob.ts b/lib/blob.ts index 976dbac..572604b 100644 --- a/lib/blob.ts +++ b/lib/blob.ts @@ -5,42 +5,76 @@ import { head, put } from "@vercel/blob"; const ONE_WEEK_SECONDS = 7 * 24 * 60 * 60; +function toPositiveInt(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +export function getFaviconTtlSeconds(): number { + return toPositiveInt(process.env.FAVICON_TTL_SECONDS, ONE_WEEK_SECONDS); +} + +export function getScreenshotTtlSeconds(): number { + return toPositiveInt(process.env.SCREENSHOT_TTL_SECONDS, ONE_WEEK_SECONDS); +} + +function getBucket(nowMs: number, ttlSeconds: number): number { + return Math.floor(nowMs / (ttlSeconds * 1000)); +} + +export function getFaviconBucket(nowMs = Date.now()): number { + return getBucket(nowMs, getFaviconTtlSeconds()); +} + +export function getScreenshotBucket(nowMs = Date.now()): number { + return getBucket(nowMs, getScreenshotTtlSeconds()); +} + function getSigningSecret(): string { if ( process.env.NODE_ENV === "production" && - !process.env.FAVICON_BLOB_SIGNING_SECRET + !process.env.BLOB_SIGNING_SECRET ) { - throw new Error("FAVICON_BLOB_SIGNING_SECRET required in production"); + throw new Error("BLOB_SIGNING_SECRET required in production"); } const secret = - process.env.FAVICON_BLOB_SIGNING_SECRET || + process.env.BLOB_SIGNING_SECRET || process.env.BLOB_READ_WRITE_TOKEN || "dev-favicon-secret"; return secret; } export function computeFaviconBlobPath(domain: string, size: number): string { - const input = `${domain}:${size}`; + const bucket = getFaviconBucket(); + const input = `${bucket}:${domain}:${size}`; const secret = getSigningSecret(); const digest = createHmac("sha256", secret).update(input).digest("hex"); // Avoid leaking domain; path is deterministic but unpredictable without secret - return `favicons/${digest}/${size}.png`; + return `favicons/${bucket}/${digest}/${size}.png`; } export async function headFaviconBlob( domain: string, size: number, ): Promise { - const pathname = computeFaviconBlobPath(domain, size); - try { - const res = await head(pathname, { - token: process.env.BLOB_READ_WRITE_TOKEN, - }); - return res?.url ?? null; - } catch { - return null; + const current = getFaviconBucket(); + const candidates = [current, current - 1]; + for (const bucket of candidates) { + const input = `${bucket}:${domain}:${size}`; + const secret = getSigningSecret(); + const digest = createHmac("sha256", secret).update(input).digest("hex"); + const pathname = `favicons/${bucket}/${digest}/${size}.png`; + try { + const res = await head(pathname, { + token: process.env.BLOB_READ_WRITE_TOKEN, + }); + if (res?.url) return res.url; + } catch { + // try next candidate + } } + return null; } export async function putFaviconBlob( @@ -48,11 +82,73 @@ export async function putFaviconBlob( size: number, png: Buffer, ): Promise { - const pathname = computeFaviconBlobPath(domain, size); + const bucket = getFaviconBucket(); + const input = `${bucket}:${domain}:${size}`; + const secret = getSigningSecret(); + const digest = createHmac("sha256", secret).update(input).digest("hex"); + const pathname = `favicons/${bucket}/${digest}/${size}.png`; const res = await put(pathname, png, { access: "public", contentType: "image/png", - cacheControlMaxAge: ONE_WEEK_SECONDS, + cacheControlMaxAge: getFaviconTtlSeconds(), + token: process.env.BLOB_READ_WRITE_TOKEN, + }); + return res.url; +} + +// Screenshot blob helpers (same HMAC hashing approach as favicons) + +export function computeScreenshotBlobPath( + domain: string, + width: number, + height: number, +): string { + const bucket = getScreenshotBucket(); + const input = `${bucket}:${domain}:${width}x${height}`; + const secret = getSigningSecret(); + const digest = createHmac("sha256", secret).update(input).digest("hex"); + return `screenshots/${bucket}/${digest}/${width}x${height}.png`; +} + +export async function headScreenshotBlob( + domain: string, + width: number, + height: number, +): Promise { + const current = getScreenshotBucket(); + const candidates = [current, current - 1]; + for (const bucket of candidates) { + const input = `${bucket}:${domain}:${width}x${height}`; + const secret = getSigningSecret(); + const digest = createHmac("sha256", secret).update(input).digest("hex"); + const pathname = `screenshots/${bucket}/${digest}/${width}x${height}.png`; + try { + const res = await head(pathname, { + token: process.env.BLOB_READ_WRITE_TOKEN, + }); + if (res?.url) return res.url; + } catch { + // try next candidate + } + } + return null; +} + +export async function putScreenshotBlob( + domain: string, + width: number, + height: number, + png: Buffer, +): Promise { + const bucket = getScreenshotBucket(); + const input = `${bucket}:${domain}:${width}x${height}`; + const secret = getSigningSecret(); + const digest = createHmac("sha256", secret).update(input).digest("hex"); + const pathname = `screenshots/${bucket}/${digest}/${width}x${height}.png`; + const res = await put(pathname, png, { + access: "public", + contentType: "image/png", + cacheControlMaxAge: getScreenshotTtlSeconds(), token: process.env.BLOB_READ_WRITE_TOKEN, }); return res.url; diff --git a/lib/image.ts b/lib/image.ts new file mode 100644 index 0000000..b583a2e --- /dev/null +++ b/lib/image.ts @@ -0,0 +1,94 @@ +import "server-only"; + +import sharp from "sharp"; + +function isIcoBuffer(buf: Buffer): boolean { + return ( + buf.length >= 4 && + buf[0] === 0x00 && + buf[1] === 0x00 && + buf[2] === 0x01 && + buf[3] === 0x00 + ); +} + +export async function convertBufferToPngCover( + input: Buffer, + width: number, + height: number, + contentTypeHint?: string | null, +): Promise { + try { + const img = sharp(input, { failOn: "none" }); + const pipeline = img + .resize(width, height, { fit: "cover" }) + .png({ compressionLevel: 9 }); + return await pipeline.toBuffer(); + } catch { + // ignore and try ICO-specific decode if it looks like ICO + } + + if (isIcoBuffer(input) || (contentTypeHint && /icon/.test(contentTypeHint))) { + try { + type IcoFrame = { + width: number; + height: number; + buffer?: ArrayBuffer; + data?: ArrayBuffer; + }; + const mod = (await import("icojs")) as unknown as { + parse: (buf: ArrayBuffer, outputType?: string) => Promise; + }; + const arr = (input.buffer as ArrayBuffer).slice( + input.byteOffset, + input.byteOffset + input.byteLength, + ) as ArrayBuffer; + const frames = await mod.parse(arr as ArrayBuffer, "image/png"); + if (Array.isArray(frames) && frames.length > 0) { + let chosen: IcoFrame = frames[0]; + chosen = frames.reduce((best: IcoFrame, cur: IcoFrame) => { + const bw = Number(best?.width ?? 0); + const bh = Number(best?.height ?? 0); + const cw = Number(cur?.width ?? 0); + const ch = Number(cur?.height ?? 0); + // Manhattan distance to target rectangle for better rectangular fit + const bDelta = Math.abs(bw - width) + Math.abs(bh - height); + const cDelta = Math.abs(cw - width) + Math.abs(ch - height); + return cDelta < bDelta ? cur : best; + }, chosen); + + const arrBuf: ArrayBuffer | undefined = chosen.buffer ?? chosen.data; + if (arrBuf) { + const pngBuf = Buffer.from(arrBuf); + return await sharp(pngBuf) + .resize(width, height, { fit: "cover" }) + .png({ compressionLevel: 9 }) + .toBuffer(); + } + } + } catch { + // Fall through to null + } + } + + return null; +} + +export async function convertBufferToSquarePng( + input: Buffer, + size: number, + contentTypeHint?: string | null, +): Promise { + return convertBufferToPngCover(input, size, size, contentTypeHint); +} + +export async function optimizePngCover( + png: Buffer, + width: number, + height: number, +): Promise { + return await sharp(png) + .resize(width, height, { fit: "cover" }) + .png({ compressionLevel: 9 }) + .toBuffer(); +} diff --git a/next.config.ts b/next.config.ts index af77e66..c59904e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -16,7 +16,9 @@ const nextConfig: NextConfig = { dynamic: 0, // disable client-side router cache for dynamic pages }, }, + serverExternalPackages: ["@sparticuz/chromium", "puppeteer-core"], images: { + unoptimized: true, remotePatterns: [ { protocol: "https", diff --git a/package.json b/package.json index 91b7f1a..108c8cd 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@posthog/nextjs-config": "^1.3.1", + "@sparticuz/chromium": "^138.0.2", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "@trpc/client": "^11.6.0", @@ -47,6 +48,7 @@ "next-themes": "^0.4.6", "posthog-js": "^1.268.6", "posthog-node": "^5.9.1", + "puppeteer-core": "^24.22.3", "radix-ui": "^1.4.3", "rdapper": "^0.1.0", "react": "19.1.1", @@ -77,6 +79,7 @@ "@vitest/ui": "3.2.4", "babel-plugin-react-compiler": "19.1.0-rc.3", "jsdom": "^27.0.0", + "puppeteer": "^24.22.3", "tailwindcss": "^4.1.13", "tw-animate-css": "^1.4.0", "typescript": "5.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0807c70..24f5295 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@posthog/nextjs-config': specifier: ^1.3.1 version: 1.3.1(next@15.6.0-canary.31(@babel/core@7.28.4)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)) + '@sparticuz/chromium': + specifier: ^138.0.2 + version: 138.0.2 '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.2(react@19.1.1) @@ -77,6 +80,9 @@ importers: posthog-node: specifier: ^5.9.1 version: 5.9.1 + puppeteer-core: + specifier: ^24.22.3 + version: 24.22.3 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -162,6 +168,9 @@ importers: jsdom: specifier: ^27.0.0 version: 27.0.0(postcss@8.5.6) + puppeteer: + specifier: ^24.22.3 + version: 24.22.3(typescript@5.9.2) tailwindcss: specifier: ^4.1.13 version: 4.1.13 @@ -837,6 +846,11 @@ packages: peerDependencies: next: '>12.1.0' + '@puppeteer/browsers@2.10.10': + resolution: {integrity: sha512-3ZG500+ZeLql8rE0hjfhkycJjDj0pI/btEh3L9IkWUYcOrgP0xCNRq3HbtbqOPbvDhFaAWD88pDFtlLv8ns8gA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1643,6 +1657,10 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sparticuz/chromium@138.0.2': + resolution: {integrity: sha512-vs5qUiK6kFCzLCxZ2buWONcB6jdF3VWdYp6kH1tt56tZ78p51dMAxfWsfk9P62z/jAeqbVg4V6Rb3Ic4aAeOKQ==} + engines: {node: '>=20.11.0'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1783,6 +1801,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@trpc/client@11.6.0': resolution: {integrity: sha512-DyWbYk2hd50BaVrXWVkaUnaSwgAF5g/lfBkXtkF1Aqlk6BtSzGUo3owPkgqQO2I5LwWy1+ra9TsSfBBvIZpTwg==} peerDependencies: @@ -1857,6 +1878,9 @@ packages: '@types/supercluster@7.1.3': resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@upstash/redis@1.35.4': resolution: {integrity: sha512-WE1ZnhFyBiIjTDW13GbO6JjkiMVVjw5VsvS8ENmvvJsze/caMQ5paxVD44+U68IUVmkXcbsLSoE+VIYsHtbQEw==} @@ -2003,6 +2027,9 @@ packages: any-base@1.1.0: resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -2026,6 +2053,10 @@ packages: resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} engines: {node: '>=0.10.0'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.5: resolution: {integrity: sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==} @@ -2041,12 +2072,53 @@ packages: axios@1.12.2: resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-plugin-react-compiler@19.1.0-rc.3: resolution: {integrity: sha512-mjRn69WuTz4adL0bXGx8Rsyk1086zFJeKmes6aK0xPuK3aaXmDJdLHqwKKMrpm6KAI1MCoUK72d2VeqQbu8YIA==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bare-events@2.7.0: + resolution: {integrity: sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==} + + bare-fs@4.4.4: + resolution: {integrity: sha512-Q8yxM1eLhJfuM7KXVP3zjhBvtMJCYRByoTT+wHXjpdMELv0xICFJX+1w4c7csa+WZEOsq4ItJ4RGwvzid6m/dw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.6.2: + resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.7.0: + resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.2.2: + resolution: {integrity: sha512-g+ueNGKkrjMazDG3elZO1pNs3HY5+mMmOet1jtKyhOaCnkLzitxf26z7hoAEkDNgdNmnc1KIlt/dw6Po6xZMpA==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2054,6 +2126,10 @@ packages: resolution: {integrity: sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ==} hasBin: true + basic-ftp@5.0.5: + resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} + engines: {node: '>=10.0.0'} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -2068,6 +2144,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -2088,6 +2167,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001745: resolution: {integrity: sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==} @@ -2106,12 +2189,21 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + chromium-bidi@9.1.0: + resolution: {integrity: sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==} + peerDependencies: + devtools-protocol: '*' + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -2151,6 +2243,15 @@ packages: core-js@3.45.1: resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==} + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2172,6 +2273,10 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + data-urls@6.0.0: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} @@ -2206,6 +2311,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2221,6 +2330,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devtools-protocol@0.0.1495869: + resolution: {integrity: sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -2249,6 +2361,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -2257,6 +2372,13 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2285,13 +2407,34 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2311,6 +2454,17 @@ packages: resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==} engines: {node: '>=0.10.0'} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2383,6 +2537,10 @@ packages: geojson-vt@4.0.2: resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2395,10 +2553,18 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + get-value@2.0.6: resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} engines: {node: '>=0.10.0'} @@ -2470,14 +2636,25 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + ipaddr.js@2.2.0: resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} engines: {node: '>= 10'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-buffer@2.0.5: resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} engines: {node: '>=4'} @@ -2558,6 +2735,10 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + jsdom@27.0.0: resolution: {integrity: sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==} engines: {node: '>=20'} @@ -2572,6 +2753,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-stringify-pretty-compact@3.0.0: resolution: {integrity: sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==} @@ -2647,6 +2831,9 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -2660,6 +2847,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lucide-react@0.544.0: resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==} peerDependencies: @@ -2723,6 +2914,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + motion-dom@12.23.21: resolution: {integrity: sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==} @@ -2758,6 +2952,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + netmask@2.0.2: + resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} + engines: {node: '>= 0.4.0'} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -2797,9 +2995,28 @@ packages: node-releases@2.0.21: resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2834,6 +3051,9 @@ packages: resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==} engines: {node: '>=14.16'} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2890,19 +3110,39 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + protocol-buffers-schema@3.6.0: resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + puppeteer-core@24.22.3: + resolution: {integrity: sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw==} + engines: {node: '>=18'} + + puppeteer@24.22.3: + resolution: {integrity: sha512-mnhXzIqSYSJ1SMv1RYH07YMzWP81xCmmQj91Q8iQMZqnf97eVzeHgsGL6kpywiGCi+nQafta/+NkwM4URMy/XQ==} + engines: {node: '>=18'} + hasBin: true + quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} @@ -2997,10 +3237,18 @@ packages: regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -3083,6 +3331,18 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -3105,6 +3365,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + splaytree@0.1.4: resolution: {integrity: sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==} @@ -3118,6 +3382,9 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3189,6 +3456,12 @@ packages: resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} engines: {node: '>=6'} + tar-fs@3.1.1: + resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.5.1: resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} engines: {node: '>=18'} @@ -3197,6 +3470,9 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + throttleit@2.1.0: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} @@ -3289,6 +3565,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + typed-query-selector@2.12.0: + resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -3450,6 +3729,9 @@ packages: web-vitals@4.2.4: resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webdriver-bidi-protocol@0.2.11: + resolution: {integrity: sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -3493,6 +3775,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -3512,6 +3797,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3519,6 +3808,20 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.11: resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} @@ -4067,6 +4370,20 @@ snapshots: transitivePeerDependencies: - debug + '@puppeteer/browsers@2.10.10': + dependencies: + debug: 4.4.3 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.7.2 + tar-fs: 3.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-buffer + - react-native-b4a + - supports-color + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -4884,6 +5201,15 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@sparticuz/chromium@138.0.2': + dependencies: + follow-redirects: 1.15.11 + tar-fs: 3.1.1 + transitivePeerDependencies: + - bare-buffer + - debug + - react-native-b4a + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -5011,6 +5337,8 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@trpc/client@11.6.0(@trpc/server@11.6.0(typescript@5.9.2))(typescript@5.9.2)': dependencies: '@trpc/server': 11.6.0(typescript@5.9.2) @@ -5088,6 +5416,11 @@ snapshots: dependencies: '@types/geojson': 7946.0.16 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.5.2 + optional: true + '@upstash/redis@1.35.4': dependencies: uncrypto: 0.1.3 @@ -5231,6 +5564,8 @@ snapshots: any-base@1.1.0: {} + argparse@2.0.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -5247,6 +5582,10 @@ snapshots: assign-symbols@1.0.0: {} + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + ast-v8-to-istanbul@0.3.5: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -5271,16 +5610,55 @@ snapshots: transitivePeerDependencies: - debug + b4a@1.7.3: {} + babel-plugin-react-compiler@19.1.0-rc.3: dependencies: '@babel/types': 7.28.4 balanced-match@1.0.2: {} + bare-events@2.7.0: {} + + bare-fs@4.4.4: + dependencies: + bare-events: 2.7.0 + bare-path: 3.0.0 + bare-stream: 2.7.0(bare-events@2.7.0) + bare-url: 2.2.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - react-native-b4a + optional: true + + bare-os@3.6.2: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.6.2 + optional: true + + bare-stream@2.7.0(bare-events@2.7.0): + dependencies: + streamx: 2.23.0 + optionalDependencies: + bare-events: 2.7.0 + transitivePeerDependencies: + - react-native-b4a + optional: true + + bare-url@2.2.2: + dependencies: + bare-path: 3.0.0 + optional: true + base64-js@1.5.1: {} baseline-browser-mapping@2.8.7: {} + basic-ftp@5.0.5: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -5299,6 +5677,8 @@ snapshots: node-releases: 2.0.21 update-browserslist-db: 1.1.3(browserslist@4.26.2) + buffer-crc32@0.2.13: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -5325,6 +5705,8 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + callsites@3.1.0: {} + caniuse-lite@1.0.30001745: {} chai@5.3.3: @@ -5341,12 +5723,24 @@ snapshots: chownr@3.0.0: {} + chromium-bidi@9.1.0(devtools-protocol@0.0.1495869): + dependencies: + devtools-protocol: 0.0.1495869 + mitt: 3.0.1 + zod: 3.25.76 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone@1.0.4: optional: true @@ -5386,6 +5780,15 @@ snapshots: core-js@3.45.1: {} + cosmiconfig@9.0.0(typescript@5.9.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5411,6 +5814,8 @@ snapshots: csstype@3.1.3: {} + data-uri-to-buffer@6.0.2: {} + data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -5442,6 +5847,12 @@ snapshots: clone: 1.0.4 optional: true + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delayed-stream@1.0.0: {} dequal@2.0.3: {} @@ -5450,6 +5861,8 @@ snapshots: detect-node-es@1.1.0: {} + devtools-protocol@0.0.1495869: {} + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -5474,6 +5887,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -5481,6 +5898,12 @@ snapshots: entities@6.0.1: {} + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -5529,12 +5952,30 @@ snapshots: escalade@3.2.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + event-target-shim@5.0.1: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.7.0 + events@3.3.0: {} exif-parser@0.1.12: {} @@ -5550,6 +5991,22 @@ snapshots: assign-symbols: 1.0.0 is-extendable: 1.0.1 + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-fifo@1.3.2: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -5606,6 +6063,8 @@ snapshots: geojson-vt@4.0.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5626,11 +6085,23 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + get-uri@6.0.5: + dependencies: + basic-ftp: 5.0.5 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + get-value@2.0.6: {} gl-matrix@3.4.4: {} @@ -5710,10 +6181,19 @@ snapshots: ieee754@1.2.1: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + indent-string@4.0.0: {} + ip-address@10.0.1: {} + ipaddr.js@2.2.0: {} + is-arrayish@0.2.1: {} + is-buffer@2.0.5: {} is-extendable@0.1.1: {} @@ -5786,6 +6266,10 @@ snapshots: js-tokens@9.0.1: {} + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + jsdom@27.0.0(postcss@8.5.6): dependencies: '@asamuzakjp/dom-selector': 6.5.6 @@ -5816,6 +6300,8 @@ snapshots: jsesc@3.1.0: {} + json-parse-even-better-errors@2.3.1: {} + json-stringify-pretty-compact@3.0.0: {} json5@2.2.3: {} @@ -5867,6 +6353,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + lines-and-columns@1.2.4: {} + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -5877,6 +6365,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: {} + lucide-react@0.544.0(react@19.1.1): dependencies: react: 19.1.1 @@ -5961,6 +6451,8 @@ snapshots: dependencies: minipass: 7.1.2 + mitt@3.0.1: {} + motion-dom@12.23.21: dependencies: motion-utils: 12.23.6 @@ -5983,6 +6475,8 @@ snapshots: nanoid@3.3.11: {} + netmask@2.0.2: {} + next-themes@0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: react: 19.1.1 @@ -6018,8 +6512,41 @@ snapshots: node-releases@2.0.21: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.0.2 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -6048,6 +6575,8 @@ snapshots: peek-readable@5.4.2: {} + pend@1.2.0: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -6096,16 +6625,68 @@ snapshots: process@0.11.10: {} + progress@2.0.3: {} + protocol-buffers-schema@3.6.0: {} + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + proxy-from-env@1.1.0: {} psl@1.15.0: dependencies: punycode: 2.3.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} + puppeteer-core@24.22.3: + dependencies: + '@puppeteer/browsers': 2.10.10 + chromium-bidi: 9.1.0(devtools-protocol@0.0.1495869) + debug: 4.4.3 + devtools-protocol: 0.0.1495869 + typed-query-selector: 2.12.0 + webdriver-bidi-protocol: 0.2.11 + ws: 8.18.3 + transitivePeerDependencies: + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + puppeteer@24.22.3(typescript@5.9.2): + dependencies: + '@puppeteer/browsers': 2.10.10 + chromium-bidi: 9.1.0(devtools-protocol@0.0.1495869) + cosmiconfig: 9.0.0(typescript@5.9.2) + devtools-protocol: 0.0.1495869 + puppeteer-core: 24.22.3 + typed-query-selector: 2.12.0 + transitivePeerDependencies: + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - typescript + - utf-8-validate + quickselect@3.0.0: {} radix-ui@1.4.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): @@ -6241,8 +6822,12 @@ snapshots: regenerator-runtime@0.13.11: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} + resolve-protobuf-schema@2.1.0: dependencies: protocol-buffers-schema: 3.6.0 @@ -6358,6 +6943,21 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.0.1 + smart-buffer: 4.2.0 + sonner@2.0.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: react: 19.1.1 @@ -6378,6 +6978,9 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.6.1: + optional: true + splaytree@0.1.4: {} split-string@3.1.0: @@ -6388,6 +6991,14 @@ snapshots: std-env@3.9.0: {} + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -6457,6 +7068,25 @@ snapshots: tapable@2.2.3: {} + tar-fs@3.1.1: + dependencies: + pump: 3.0.3 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.4.4 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-buffer + - react-native-b4a + + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - react-native-b4a + tar@7.5.1: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -6471,6 +7101,12 @@ snapshots: glob: 10.4.5 minimatch: 9.0.5 + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + throttleit@2.1.0: {} tinybench@2.9.0: {} @@ -6537,6 +7173,8 @@ snapshots: tw-animate-css@1.4.0: {} + typed-query-selector@2.12.0: {} + typescript@5.9.2: {} typewise-core@1.2.0: {} @@ -6698,6 +7336,8 @@ snapshots: web-vitals@4.2.4: {} + webdriver-bidi-protocol@0.2.11: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.0: {} @@ -6741,14 +7381,37 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrappy@1.0.2: {} + ws@8.18.3: {} xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@5.0.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + zod@3.25.76: {} + zod@4.1.11: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2e5082c..43f49c2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,4 +3,5 @@ onlyBuiltDependencies: - '@tailwindcss/oxide' - core-js - esbuild + - puppeteer - sharp diff --git a/server/routers/domain.ts b/server/routers/domain.ts index dd45ade..1ba49e0 100644 --- a/server/routers/domain.ts +++ b/server/routers/domain.ts @@ -3,6 +3,7 @@ import { getOrCreateFaviconBlobUrl } from "../services/favicon"; import { probeHeaders } from "../services/headers"; import { detectHosting } from "../services/hosting"; import { getRegistration } from "../services/registration"; +import { getOrCreateScreenshotBlobUrl } from "../services/screenshot"; import { getCertificates } from "../services/tls"; import { router } from "../trpc"; import { createDomainProcedure } from "./domain-procedure"; @@ -23,4 +24,8 @@ export const domainRouter = router({ getOrCreateFaviconBlobUrl, "Favicon fetch failed", ), + screenshot: createDomainProcedure( + getOrCreateScreenshotBlobUrl, + "Screenshot capture failed", + ), }); diff --git a/server/services/constants.ts b/server/services/constants.ts new file mode 100644 index 0000000..36abbfe --- /dev/null +++ b/server/services/constants.ts @@ -0,0 +1,2 @@ +export const USER_AGENT = + process.env.HOOT_USER_AGENT || "hoot.sh/0.1 (+https://hoot.sh)"; diff --git a/server/services/doh-providers.ts b/server/services/doh-providers.ts index 7689ddc..ff8e4ee 100644 --- a/server/services/doh-providers.ts +++ b/server/services/doh-providers.ts @@ -1,3 +1,4 @@ +import { USER_AGENT } from "./constants"; export type DnsRecordType = "A" | "AAAA" | "MX" | "TXT" | "NS"; export type DohProviderKey = "cloudflare" | "google"; @@ -10,7 +11,7 @@ export type DohProvider = { const DEFAULT_HEADERS: Record = { accept: "application/dns-json", - "user-agent": "hoot.sh/0.1 (+https://hoot.sh)", + "user-agent": USER_AGENT, }; export const DOH_PROVIDERS: DohProvider[] = [ diff --git a/server/services/favicon.ts b/server/services/favicon.ts index 46ea273..6f83328 100644 --- a/server/services/favicon.ts +++ b/server/services/favicon.ts @@ -1,22 +1,13 @@ -import sharp from "sharp"; import { captureServer } from "@/lib/analytics/server"; import { headFaviconBlob, putFaviconBlob } from "@/lib/blob"; +import { convertBufferToSquarePng } from "@/lib/image"; +import { USER_AGENT } from "./constants"; const DEFAULT_SIZE = 32; const REQUEST_TIMEOUT_MS = 1500; // per each method // Legacy Redis-based caching removed; Blob is now the canonical store -function isIcoBuffer(buf: Buffer): boolean { - return ( - buf.length >= 4 && - buf[0] === 0x00 && - buf[1] === 0x00 && - buf[2] === 0x01 && - buf[3] === 0x00 - ); -} - async function fetchWithTimeout( url: string, init?: RequestInit, @@ -28,7 +19,7 @@ async function fetchWithTimeout( redirect: "follow", headers: { Accept: "image/avif,image/webp,image/png,image/*;q=0.9,*/*;q=0.8", - "User-Agent": "hoot.sh/0.1 (+https://hoot.sh)", + "User-Agent": USER_AGENT, }, signal: controller.signal, ...init, @@ -44,59 +35,7 @@ async function convertToPng( contentType: string | null, size: number, ): Promise { - try { - const img = sharp(input, { failOn: "none" }); - const pipeline = img - .resize(size, size, { fit: "cover" }) - .png({ compressionLevel: 9 }); - return await pipeline.toBuffer(); - } catch { - // ignore and try ICO-specific decode if it looks like ICO - } - - if (isIcoBuffer(input) || (contentType && /icon/.test(contentType))) { - try { - type IcoFrame = { - width: number; - height: number; - buffer?: ArrayBuffer; - data?: ArrayBuffer; - }; - const mod = (await import("icojs")) as unknown as { - parse: (buf: ArrayBuffer, outputType?: string) => Promise; - }; - const arr = (input.buffer as ArrayBuffer).slice( - input.byteOffset, - input.byteOffset + input.byteLength, - ) as ArrayBuffer; - const frames = await mod.parse(arr as ArrayBuffer, "image/png"); - if (Array.isArray(frames) && frames.length > 0) { - let chosen: IcoFrame = frames[0]; - chosen = frames.reduce((best: IcoFrame, cur: IcoFrame) => { - const bw = Number(best?.width ?? 0); - const cw = Number(cur?.width ?? 0); - const bh = Number(best?.height ?? 0); - const ch = Number(cur?.height ?? 0); - const bDelta = Math.abs(Math.max(bw, bh) - size); - const cDelta = Math.abs(Math.max(cw, ch) - size); - return cDelta < bDelta ? cur : best; - }, chosen); - - const arrBuf: ArrayBuffer | undefined = chosen.buffer ?? chosen.data; - if (arrBuf) { - const pngBuf = Buffer.from(arrBuf); - return await sharp(pngBuf) - .resize(size, size, { fit: "cover" }) - .png({ compressionLevel: 9 }) - .toBuffer(); - } - } - } catch { - // Fall through to null - } - } - - return null; + return convertBufferToSquarePng(input, size, contentType); } function buildSources(domain: string): string[] { diff --git a/server/services/screenshot.test.ts b/server/services/screenshot.test.ts new file mode 100644 index 0000000..350cba8 --- /dev/null +++ b/server/services/screenshot.test.ts @@ -0,0 +1,61 @@ +/* @vitest-environment node */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const blobMock = vi.hoisted(() => ({ + headScreenshotBlob: vi.fn(), + putScreenshotBlob: vi.fn(async () => "blob://stored-screenshot"), +})); + +vi.mock("@/lib/blob", () => blobMock); + +// Mock puppeteer environments +const pageMock = { + setViewport: vi.fn(async () => undefined), + setUserAgent: vi.fn(async () => undefined), + goto: vi.fn(async () => undefined), + screenshot: vi.fn(async () => Buffer.from([1, 2, 3])), +}; +const browserMock = { + newPage: vi.fn(async () => pageMock), + close: vi.fn(async () => undefined), +}; + +vi.mock("puppeteer", () => ({ + launch: vi.fn(async () => browserMock), +})); +vi.mock("puppeteer-core", () => ({ + launch: vi.fn(async () => browserMock), +})); + +// Optimize does a simple pass-through for test speed +vi.mock("@/lib/image", () => ({ + optimizePngCover: vi.fn(async (b: Buffer) => b), +})); + +import { getOrCreateScreenshotBlobUrl } from "./screenshot"; + +beforeEach(() => { + process.env.VERCEL_ENV = ""; // force local puppeteer path in tests +}); + +afterEach(() => { + vi.restoreAllMocks(); + blobMock.headScreenshotBlob.mockReset(); + blobMock.putScreenshotBlob.mockReset(); +}); + +describe("getOrCreateScreenshotBlobUrl", () => { + it("returns existing blob url when present", async () => { + blobMock.headScreenshotBlob.mockResolvedValueOnce("blob://existing"); + const out = await getOrCreateScreenshotBlobUrl("example.com"); + expect(out.url).toBe("blob://existing"); + expect(blobMock.putScreenshotBlob).not.toHaveBeenCalled(); + }); + + it("captures, uploads and returns url when not cached", async () => { + blobMock.headScreenshotBlob.mockResolvedValueOnce(null); + const out = await getOrCreateScreenshotBlobUrl("example.com"); + expect(out.url).toBe("blob://stored-screenshot"); + expect(blobMock.putScreenshotBlob).toHaveBeenCalled(); + }); +}); diff --git a/server/services/screenshot.ts b/server/services/screenshot.ts new file mode 100644 index 0000000..0bd3720 --- /dev/null +++ b/server/services/screenshot.ts @@ -0,0 +1,230 @@ +import { captureServer } from "@/lib/analytics/server"; +import { headScreenshotBlob, putScreenshotBlob } from "@/lib/blob"; +import { optimizePngCover } from "@/lib/image"; +import { USER_AGENT } from "./constants"; + +const VIEWPORT_WIDTH = 1200; +const VIEWPORT_HEIGHT = 630; +const NAV_TIMEOUT_MS = 8000; + +function buildHomepageUrls(domain: string): string[] { + return [`https://${domain}`, `http://${domain}`]; +} + +export async function getOrCreateScreenshotBlobUrl( + domain: string, + opts?: { distinctId?: string }, +): Promise<{ url: string | null }> { + const startedAt = Date.now(); + + // 1) Check existing blob + try { + const existing = await headScreenshotBlob( + domain, + VIEWPORT_WIDTH, + VIEWPORT_HEIGHT, + ); + if (existing) { + await captureServer( + "screenshot_capture", + { + domain, + width: VIEWPORT_WIDTH, + height: VIEWPORT_HEIGHT, + source: "blob", + duration_ms: Date.now() - startedAt, + outcome: "ok", + cache: "hit_blob", + }, + opts?.distinctId, + ); + return { url: existing }; + } + } catch { + // ignore and proceed + } + + // 2) Attempt to capture + let browser: import("puppeteer-core").Browser | null = null; + try { + const isVercel = process.env.VERCEL === "1"; + const isLinux = process.platform === "linux"; + const preferChromium = isLinux || isVercel; + + type LaunchFn = ( + options?: Record, + ) => Promise; + let puppeteerLaunch: LaunchFn = async () => { + throw new Error("puppeteer launcher not configured"); + }; + let launchOptions: Record = { headless: true }; + let launcherMode: "chromium" | "puppeteer" = preferChromium + ? "chromium" + : "puppeteer"; + + async function setupChromium() { + const chromium = (await import("@sparticuz/chromium")).default; + const core = await import("puppeteer-core"); + puppeteerLaunch = core.launch as unknown as LaunchFn; + launchOptions = { + ...launchOptions, + args: chromium.args, + executablePath: await chromium.executablePath(), + }; + + console.debug("[screenshot] using chromium", { + executablePath: (launchOptions as { executablePath?: unknown }) + .executablePath, + }); + } + + async function setupPuppeteer() { + const full = await import("puppeteer"); + puppeteerLaunch = (full as unknown as { launch: LaunchFn }).launch; + const path = process.env.PUPPETEER_EXECUTABLE_PATH; + launchOptions = { + ...launchOptions, + ...(path ? { executablePath: path } : {}), + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + ], + }; + + console.debug("[screenshot] using puppeteer", { + executablePath: path || null, + }); + } + + // First attempt based on platform preference + try { + if (launcherMode === "chromium") await setupChromium(); + else await setupPuppeteer(); + // Try launch + + console.debug("[screenshot] launching browser", { mode: launcherMode }); + browser = await puppeteerLaunch(launchOptions); + } catch (firstErr) { + console.warn("[screenshot] first launch attempt failed", { + mode: launcherMode, + error: (firstErr as Error)?.message, + }); + // Flip mode and retry once + launcherMode = launcherMode === "chromium" ? "puppeteer" : "chromium"; + try { + if (launcherMode === "chromium") await setupChromium(); + else await setupPuppeteer(); + + console.debug("[screenshot] retry launching browser", { + mode: launcherMode, + }); + browser = await puppeteerLaunch(launchOptions); + } catch (secondErr) { + console.error("[screenshot] both launch attempts failed", { + first_error: (firstErr as Error)?.message, + second_error: (secondErr as Error)?.message, + }); + throw secondErr; + } + } + + console.debug("[screenshot] browser launched", { mode: launcherMode }); + + const tryUrls = buildHomepageUrls(domain); + for (const url of tryUrls) { + try { + const page = await browser.newPage(); + await page.setViewport({ + width: VIEWPORT_WIDTH, + height: VIEWPORT_HEIGHT, + deviceScaleFactor: 1, + }); + await page.setUserAgent(USER_AGENT); + + console.debug("[screenshot] navigating", { url }); + await page.goto(url, { + waitUntil: "networkidle2", + timeout: NAV_TIMEOUT_MS, + }); + + console.debug("[screenshot] navigated", { url }); + + const rawPng: Buffer = (await page.screenshot({ + type: "png", + fullPage: false, + })) as Buffer; + + const png = await optimizePngCover( + rawPng, + VIEWPORT_WIDTH, + VIEWPORT_HEIGHT, + ); + if (png && png.length > 0) { + const storedUrl = await putScreenshotBlob( + domain, + VIEWPORT_WIDTH, + VIEWPORT_HEIGHT, + png, + ); + + console.info("[screenshot] stored blob", { url: storedUrl }); + + await captureServer( + "screenshot_capture", + { + domain, + width: VIEWPORT_WIDTH, + height: VIEWPORT_HEIGHT, + source: url.startsWith("https://") + ? "direct_https" + : "direct_http", + duration_ms: Date.now() - startedAt, + outcome: "ok", + cache: "store_blob", + }, + opts?.distinctId, + ); + + return { url: storedUrl }; + } + } catch (err) { + // try next URL + + console.warn("[screenshot] attempt failed", { + url, + error: (err as Error)?.message, + }); + } + } + } catch (err) { + // fallthrough to not_found + + console.error("[screenshot] capture failed", { + domain, + error: (err as Error)?.message, + }); + } finally { + if (browser) { + try { + await browser.close(); + } catch {} + } + } + + await captureServer( + "screenshot_capture", + { + domain, + width: VIEWPORT_WIDTH, + height: VIEWPORT_HEIGHT, + duration_ms: Date.now() - startedAt, + outcome: "not_found", + cache: "miss", + }, + opts?.distinctId, + ); + + console.warn("[screenshot] returning null", { domain }); + return { url: null }; +} diff --git a/vercel.json b/vercel.json index 08a173c..4c75199 100644 --- a/vercel.json +++ b/vercel.json @@ -2,7 +2,14 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "build": { "env": { - "ENABLE_EXPERIMENTAL_COREPACK": "1" + "ENABLE_EXPERIMENTAL_COREPACK": "1", + "PUPPETEER_SKIP_DOWNLOAD": "1" } - } + }, + "crons": [ + { + "path": "/api/cron/blob-prune", + "schedule": "0 3 * * *" + } + ] }