mirror of
https://github.com/jakejarvis/hoot.git
synced 2025-10-18 14:24:26 -04:00
Refactor TRPC client query handling to use stable query client and improve hydration behavior
This commit is contained in:
@@ -34,11 +34,7 @@ export function DomainPricingCTA({
|
||||
{ domain },
|
||||
{
|
||||
enabled: !!domain,
|
||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@@ -29,9 +29,8 @@ export function Favicon({
|
||||
trpc.domain.favicon.queryOptions(
|
||||
{ domain },
|
||||
{
|
||||
staleTime: 60 * 60_000, // 1 hour
|
||||
placeholderData: (prev) => prev,
|
||||
enabled: isHydrated,
|
||||
placeholderData: (prev) => prev,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@@ -32,9 +32,8 @@ export function Screenshot({
|
||||
trpc.domain.screenshot.queryOptions(
|
||||
{ domain },
|
||||
{
|
||||
staleTime: 24 * 60 * 60_000, // 24h in ms
|
||||
retry: 5,
|
||||
enabled,
|
||||
retry: 5,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import {
|
||||
Popover as UiPopover,
|
||||
PopoverContent as UiPopoverContent,
|
||||
PopoverTrigger as UiPopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip as UiTooltip,
|
||||
TooltipContent as UiTooltipContent,
|
||||
TooltipProvider as UiTooltipProvider,
|
||||
TooltipTrigger as UiTooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePreferPopoverForTooltip } from "@/hooks/use-pointer-capability";
|
||||
|
||||
type Variant = "tooltip" | "popover";
|
||||
|
||||
const VariantContext = createContext<Variant>("tooltip");
|
||||
|
||||
export type HybridTooltipProps =
|
||||
| ({ forceVariant?: Variant } & React.ComponentProps<typeof UiTooltip>)
|
||||
| ({ forceVariant?: Variant } & React.ComponentProps<typeof UiPopover>);
|
||||
|
||||
/**
|
||||
* HybridTooltip switches between Tooltip (desktop/hover) and Popover (touch/coarse) at runtime.
|
||||
* It preserves the familiar Tooltip API while providing tap-to-open behavior on touch devices.
|
||||
*
|
||||
* Props mirror the shadcn Tooltip/Popover roots. Prefer controlled props when needed.
|
||||
*/
|
||||
export function HybridTooltip({ forceVariant, ...props }: HybridTooltipProps) {
|
||||
const preferPopover = usePreferPopoverForTooltip();
|
||||
// Default to tooltip for SSR/hydration safety; only switch after mount via hook.
|
||||
const variant: Variant = useMemo(() => {
|
||||
return forceVariant ?? (preferPopover ? "popover" : "tooltip");
|
||||
}, [forceVariant, preferPopover]);
|
||||
|
||||
if (variant === "popover") {
|
||||
return (
|
||||
<VariantContext.Provider value="popover">
|
||||
<UiPopover {...(props as React.ComponentProps<typeof UiPopover>)} />
|
||||
</VariantContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VariantContext.Provider value="tooltip">
|
||||
<UiTooltipProvider>
|
||||
<UiTooltip {...(props as React.ComponentProps<typeof UiTooltip>)} />
|
||||
</UiTooltipProvider>
|
||||
</VariantContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type HybridTooltipTriggerProps =
|
||||
| React.ComponentProps<typeof UiTooltipTrigger>
|
||||
| React.ComponentProps<typeof UiPopoverTrigger>;
|
||||
|
||||
export function HybridTooltipTrigger(props: HybridTooltipTriggerProps) {
|
||||
const variant = useContext(VariantContext);
|
||||
return variant === "popover" ? (
|
||||
<UiPopoverTrigger
|
||||
{...(props as React.ComponentProps<typeof UiPopoverTrigger>)}
|
||||
/>
|
||||
) : (
|
||||
<UiTooltipTrigger
|
||||
{...(props as React.ComponentProps<typeof UiTooltipTrigger>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type HybridTooltipContentProps =
|
||||
| (React.ComponentProps<typeof UiTooltipContent> & { hideArrow?: boolean })
|
||||
| React.ComponentProps<typeof UiPopoverContent>;
|
||||
|
||||
export function HybridTooltipContent({
|
||||
hideArrow,
|
||||
...props
|
||||
}: HybridTooltipContentProps & { hideArrow?: boolean }) {
|
||||
const variant = useContext(VariantContext);
|
||||
return variant === "popover" ? (
|
||||
<UiPopoverContent
|
||||
{...(props as React.ComponentProps<typeof UiPopoverContent>)}
|
||||
/>
|
||||
) : (
|
||||
<UiTooltipContent
|
||||
{...(props as React.ComponentProps<typeof UiTooltipContent>)}
|
||||
hideArrow={hideArrow}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -7,12 +7,8 @@ export function useDomainQueries(domain: string) {
|
||||
trpc.domain.registration.queryOptions(
|
||||
{ domain },
|
||||
{
|
||||
staleTime: 30 * 60_000, // 30 minutes, avoid churn
|
||||
// Keep UI stable during transitions by reusing previous data
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -22,11 +18,7 @@ export function useDomainQueries(domain: string) {
|
||||
{ domain },
|
||||
{
|
||||
enabled: registration.data?.isRegistered,
|
||||
staleTime: 30 * 60_000, // 30 minutes
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -39,11 +31,7 @@ export function useDomainQueries(domain: string) {
|
||||
// reuse warm caches server-side. If DNS errored, still allow hosting to run.
|
||||
enabled:
|
||||
registration.data?.isRegistered && (dns.isSuccess || dns.isError),
|
||||
staleTime: 30 * 60_000, // 30 minutes
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -53,11 +41,7 @@ export function useDomainQueries(domain: string) {
|
||||
{ domain },
|
||||
{
|
||||
enabled: registration.data?.isRegistered,
|
||||
staleTime: 30 * 60_000, // 30 minutes
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -67,11 +51,7 @@ export function useDomainQueries(domain: string) {
|
||||
{ domain },
|
||||
{
|
||||
enabled: registration.data?.isRegistered,
|
||||
staleTime: 30 * 60_000, // 30 minutes
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -81,11 +61,7 @@ export function useDomainQueries(domain: string) {
|
||||
{ domain },
|
||||
{
|
||||
enabled: registration.data?.isRegistered,
|
||||
staleTime: 30 * 60_000, // 30 minutes
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
104
lib/cache.test.ts
Normal file
104
lib/cache.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getOrCreateCachedAsset } from "@/lib/cache";
|
||||
|
||||
const ns = (...parts: string[]) => parts.join(":");
|
||||
|
||||
describe("cached assets", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.__redisTestHelper.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns cached URL on hit", async () => {
|
||||
const indexKey = ns("test", "asset");
|
||||
const lockKey = ns("lock", "test", "asset");
|
||||
|
||||
// seed cache
|
||||
await (await import("@/lib/redis")).redis.set(indexKey, {
|
||||
url: "https://cdn/x.webp",
|
||||
});
|
||||
|
||||
const result = await getOrCreateCachedAsset<{ source: string }>({
|
||||
indexKey,
|
||||
lockKey,
|
||||
ttlSeconds: 60,
|
||||
eventName: "test_asset",
|
||||
baseMetrics: { domain: "example.com" },
|
||||
produceAndUpload: async () => ({
|
||||
url: "https://cdn/y.webp",
|
||||
key: "k",
|
||||
metrics: { source: "upload" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ url: "https://cdn/x.webp" });
|
||||
});
|
||||
|
||||
it("waits for result when lock not acquired and cached result exists", async () => {
|
||||
const indexKey = ns("test", "asset2");
|
||||
const lockKey = ns("lock", "test", "asset2");
|
||||
|
||||
// Simulate another worker already storing result
|
||||
const { redis } = await import("@/lib/redis");
|
||||
await redis.set(lockKey, "1");
|
||||
await redis.set(indexKey, { url: "https://cdn/wait.webp" });
|
||||
|
||||
const result = await getOrCreateCachedAsset<{ source: string }>({
|
||||
indexKey,
|
||||
lockKey,
|
||||
ttlSeconds: 60,
|
||||
eventName: "test_asset",
|
||||
baseMetrics: { domain: "example.com" },
|
||||
produceAndUpload: async () => ({ url: "https://cdn/unused.webp" }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ url: "https://cdn/wait.webp" });
|
||||
});
|
||||
|
||||
it("produces, stores, and returns new asset under lock", async () => {
|
||||
const indexKey = ns("test", "asset3");
|
||||
const lockKey = ns("lock", "test", "asset3");
|
||||
|
||||
const result = await getOrCreateCachedAsset<{ source: string }>({
|
||||
indexKey,
|
||||
lockKey,
|
||||
ttlSeconds: 60,
|
||||
eventName: "test_asset",
|
||||
baseMetrics: { domain: "example.com" },
|
||||
purgeQueue: "purge-test",
|
||||
produceAndUpload: async () => ({
|
||||
url: "https://cdn/new.webp",
|
||||
key: "object-key",
|
||||
metrics: { source: "upload" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ url: "https://cdn/new.webp" });
|
||||
|
||||
const { redis } = await import("@/lib/redis");
|
||||
const stored = (await redis.get(indexKey)) as {
|
||||
url?: string;
|
||||
key?: string;
|
||||
} | null;
|
||||
expect(stored?.url).toBe("https://cdn/new.webp");
|
||||
});
|
||||
|
||||
it("propagates not_found via cached null", async () => {
|
||||
const indexKey = ns("test", "asset4");
|
||||
const lockKey = ns("lock", "test", "asset4");
|
||||
|
||||
const result = await getOrCreateCachedAsset<{ source: string }>({
|
||||
indexKey,
|
||||
lockKey,
|
||||
ttlSeconds: 60,
|
||||
eventName: "test_asset",
|
||||
baseMetrics: { domain: "example.com" },
|
||||
produceAndUpload: async () => ({ url: null }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ url: null });
|
||||
});
|
||||
});
|
246
lib/cache.ts
Normal file
246
lib/cache.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { captureServer } from "@/lib/analytics/server";
|
||||
import { ns, redis } from "@/lib/redis";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
type LockResult<T = unknown> =
|
||||
| { acquired: true; cachedResult: null }
|
||||
| { acquired: false; cachedResult: T | null };
|
||||
|
||||
/**
|
||||
* Acquire a Redis lock or wait for result from another process
|
||||
*
|
||||
* This prevents duplicate work by:
|
||||
* 1. Trying to acquire lock with NX (only set if not exists)
|
||||
* 2. If lock acquired, caller does work and caches result
|
||||
* 3. If lock NOT acquired, poll for cached result until timeout
|
||||
*
|
||||
* @param options.lockKey - Redis key for the lock
|
||||
* @param options.resultKey - Redis key where result will be cached
|
||||
* @param options.lockTtl - Lock TTL in seconds (default 30)
|
||||
* @param options.pollIntervalMs - How often to check for result (default 250ms)
|
||||
* @param options.maxWaitMs - Max time to wait for result (default 25000ms)
|
||||
* @returns Lock status and any cached result
|
||||
*/
|
||||
export async function acquireLockOrWaitForResult<T = unknown>(options: {
|
||||
lockKey: string;
|
||||
resultKey: string;
|
||||
lockTtl?: number;
|
||||
pollIntervalMs?: number;
|
||||
maxWaitMs?: number;
|
||||
}): Promise<LockResult<T>> {
|
||||
const {
|
||||
lockKey,
|
||||
resultKey,
|
||||
lockTtl = 30,
|
||||
pollIntervalMs = 250,
|
||||
maxWaitMs = 25000,
|
||||
} = options;
|
||||
|
||||
// Try to acquire lock
|
||||
try {
|
||||
const setRes = await redis.set(lockKey, "1", {
|
||||
nx: true,
|
||||
ex: lockTtl,
|
||||
});
|
||||
const acquired = setRes === "OK" || setRes === undefined;
|
||||
|
||||
if (acquired) {
|
||||
console.debug("[redis] lock acquired", { lockKey });
|
||||
return { acquired: true, cachedResult: null };
|
||||
}
|
||||
|
||||
console.debug("[redis] lock not acquired, waiting for result", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
maxWaitMs,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[redis] lock acquisition failed", {
|
||||
lockKey,
|
||||
error: (err as Error)?.message,
|
||||
});
|
||||
// If Redis is down, fail open (don't wait)
|
||||
return { acquired: true, cachedResult: null };
|
||||
}
|
||||
|
||||
// Lock not acquired, poll for result
|
||||
const startTime = Date.now();
|
||||
let pollCount = 0;
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
pollCount++;
|
||||
const result = (await redis.get(resultKey)) as T | null;
|
||||
|
||||
if (result !== null) {
|
||||
console.debug("[redis] found cached result while waiting", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
pollCount,
|
||||
waitedMs: Date.now() - startTime,
|
||||
});
|
||||
return { acquired: false, cachedResult: result };
|
||||
}
|
||||
|
||||
// Check if lock still exists - if not, the other process may have failed
|
||||
const lockExists = await redis.exists(lockKey);
|
||||
if (!lockExists) {
|
||||
console.warn("[redis] lock disappeared without result", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
pollCount,
|
||||
});
|
||||
// Lock gone but no result - other process likely failed
|
||||
// Try to acquire lock ourselves
|
||||
const retryRes = await redis.set(lockKey, "1", {
|
||||
nx: true,
|
||||
ex: lockTtl,
|
||||
});
|
||||
const retryAcquired = retryRes === "OK" || retryRes === undefined;
|
||||
if (retryAcquired) {
|
||||
return { acquired: true, cachedResult: null };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[redis] error polling for result", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
error: (err as Error)?.message,
|
||||
});
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
console.warn("[redis] wait timeout, no result found", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
pollCount,
|
||||
waitedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return { acquired: false, cachedResult: null };
|
||||
}
|
||||
|
||||
type CachedAssetOptions<TProduceMeta extends Record<string, unknown>> = {
|
||||
indexKey: string;
|
||||
lockKey: string;
|
||||
ttlSeconds: number;
|
||||
eventName: string;
|
||||
baseMetrics?: Record<string, unknown>;
|
||||
/**
|
||||
* Produce and upload the asset, returning { url, key } and any metrics to attach
|
||||
*/
|
||||
produceAndUpload: () => Promise<{
|
||||
url: string | null;
|
||||
key?: string;
|
||||
metrics?: TProduceMeta;
|
||||
}>;
|
||||
/**
|
||||
* Purge queue name (zset) for scheduling deletes by expiresAtMs
|
||||
* If provided and key is returned, will zadd(key, expiresAtMs)
|
||||
*/
|
||||
purgeQueue?: string;
|
||||
};
|
||||
|
||||
export async function getOrCreateCachedAsset<T extends Record<string, unknown>>(
|
||||
options: CachedAssetOptions<T>,
|
||||
): Promise<{ url: string | null }> {
|
||||
const {
|
||||
indexKey,
|
||||
lockKey,
|
||||
ttlSeconds,
|
||||
eventName,
|
||||
baseMetrics,
|
||||
produceAndUpload,
|
||||
purgeQueue,
|
||||
} = options;
|
||||
const startedAt = Date.now();
|
||||
|
||||
// 1) Check index
|
||||
try {
|
||||
const raw = (await redis.get(indexKey)) as { url?: unknown } | null;
|
||||
if (raw && typeof raw === "object") {
|
||||
const cachedUrl = (raw as { url?: unknown }).url;
|
||||
if (typeof cachedUrl === "string") {
|
||||
await captureServer(eventName, {
|
||||
...baseMetrics,
|
||||
source: "redis",
|
||||
duration_ms: Date.now() - startedAt,
|
||||
outcome: "ok",
|
||||
cache: "hit",
|
||||
});
|
||||
return { url: cachedUrl };
|
||||
}
|
||||
if (cachedUrl === null) {
|
||||
await captureServer(eventName, {
|
||||
...baseMetrics,
|
||||
source: "redis",
|
||||
duration_ms: Date.now() - startedAt,
|
||||
outcome: "not_found",
|
||||
cache: "hit",
|
||||
});
|
||||
return { url: null };
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 2) Acquire lock or wait
|
||||
const lockResult = await acquireLockOrWaitForResult<{ url: string | null }>({
|
||||
lockKey,
|
||||
resultKey: indexKey,
|
||||
lockTtl: Math.max(5, Math.min(120, ttlSeconds)),
|
||||
});
|
||||
|
||||
if (!lockResult.acquired) {
|
||||
const cached = lockResult.cachedResult;
|
||||
if (cached && typeof cached === "object" && "url" in cached) {
|
||||
const cachedUrl = (cached as { url: string | null }).url;
|
||||
await captureServer(eventName, {
|
||||
...baseMetrics,
|
||||
source: "redis_wait",
|
||||
duration_ms: Date.now() - startedAt,
|
||||
outcome: cachedUrl ? "ok" : "not_found",
|
||||
cache: "wait",
|
||||
});
|
||||
return { url: cachedUrl };
|
||||
}
|
||||
return { url: null };
|
||||
}
|
||||
|
||||
// 3) Do work under lock
|
||||
try {
|
||||
const produced = await produceAndUpload();
|
||||
const expiresAtMs = Date.now() + ttlSeconds * 1000;
|
||||
|
||||
try {
|
||||
await redis.set(
|
||||
indexKey,
|
||||
{ url: produced.url, key: produced.key, expiresAtMs },
|
||||
{ ex: ttlSeconds },
|
||||
);
|
||||
if (purgeQueue && produced.key) {
|
||||
await redis.zadd(ns("purge", purgeQueue), {
|
||||
score: expiresAtMs,
|
||||
member: produced.key,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await captureServer(eventName, {
|
||||
...baseMetrics,
|
||||
...(produced.metrics ?? {}),
|
||||
duration_ms: Date.now() - startedAt,
|
||||
outcome: produced.url ? "ok" : "not_found",
|
||||
cache: "store",
|
||||
});
|
||||
return { url: produced.url };
|
||||
} finally {
|
||||
try {
|
||||
await redis.del(lockKey);
|
||||
} catch {}
|
||||
}
|
||||
}
|
124
lib/cache/cached-asset.ts
vendored
124
lib/cache/cached-asset.ts
vendored
@@ -1,124 +0,0 @@
|
||||
import { captureServer } from "@/lib/analytics/server";
|
||||
import { ns, redis } from "@/lib/redis";
|
||||
|
||||
type CachedAssetOptions<TProduceMeta extends Record<string, unknown>> = {
|
||||
indexKey: string;
|
||||
lockKey: string;
|
||||
ttlSeconds: number;
|
||||
eventName: string;
|
||||
baseMetrics?: Record<string, unknown>;
|
||||
/**
|
||||
* Produce and upload the asset, returning { url, key } and any metrics to attach
|
||||
*/
|
||||
produceAndUpload: () => Promise<{
|
||||
url: string | null;
|
||||
key?: string;
|
||||
metrics?: TProduceMeta;
|
||||
}>;
|
||||
/**
|
||||
* Purge queue name (zset) for scheduling deletes by expiresAtMs
|
||||
* If provided and key is returned, will zadd(key, expiresAtMs)
|
||||
*/
|
||||
purgeQueue?: string;
|
||||
};
|
||||
|
||||
export async function getOrCreateCachedAsset<T extends Record<string, unknown>>(
|
||||
options: CachedAssetOptions<T>,
|
||||
): Promise<{ url: string | null }> {
|
||||
const {
|
||||
indexKey,
|
||||
lockKey,
|
||||
ttlSeconds,
|
||||
eventName,
|
||||
baseMetrics,
|
||||
produceAndUpload,
|
||||
purgeQueue,
|
||||
} = options;
|
||||
const startedAt = Date.now();
|
||||
|
||||
// 1) Check index
|
||||
try {
|
||||
const raw = (await redis.get(indexKey)) as { url?: unknown } | null;
|
||||
if (raw && typeof raw === "object") {
|
||||
const cachedUrl = (raw as { url?: unknown }).url;
|
||||
if (typeof cachedUrl === "string") {
|
||||
await captureServer(eventName, {
|
||||
...baseMetrics,
|
||||
source: "redis",
|
||||
duration_ms: Date.now() - startedAt,
|
||||
outcome: "ok",
|
||||
cache: "hit",
|
||||
});
|
||||
return { url: cachedUrl };
|
||||
}
|
||||
if (cachedUrl === null) {
|
||||
await captureServer(eventName, {
|
||||
...baseMetrics,
|
||||
source: "redis",
|
||||
duration_ms: Date.now() - startedAt,
|
||||
outcome: "not_found",
|
||||
cache: "hit",
|
||||
});
|
||||
return { url: null };
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 2) Acquire lock or wait
|
||||
// Reuse redis.ts helper rather than duplicating. Import here lazily to avoid cycles.
|
||||
const { acquireLockOrWaitForResult } = await import("@/lib/redis");
|
||||
const lockResult = await acquireLockOrWaitForResult<{ url: string | null }>({
|
||||
lockKey,
|
||||
resultKey: indexKey,
|
||||
lockTtl: Math.max(5, Math.min(120, ttlSeconds)),
|
||||
});
|
||||
|
||||
if (!lockResult.acquired) {
|
||||
const cached = lockResult.cachedResult;
|
||||
if (cached && typeof cached === "object" && "url" in cached) {
|
||||
const cachedUrl = (cached as { url: string | null }).url;
|
||||
await captureServer(eventName, {
|
||||
...baseMetrics,
|
||||
source: "redis_wait",
|
||||
duration_ms: Date.now() - startedAt,
|
||||
outcome: cachedUrl ? "ok" : "not_found",
|
||||
cache: "wait",
|
||||
});
|
||||
return { url: cachedUrl };
|
||||
}
|
||||
return { url: null };
|
||||
}
|
||||
|
||||
// 3) Do work under lock
|
||||
try {
|
||||
const produced = await produceAndUpload();
|
||||
const expiresAtMs = Date.now() + ttlSeconds * 1000;
|
||||
|
||||
try {
|
||||
await redis.set(
|
||||
indexKey,
|
||||
{ url: produced.url, key: produced.key, expiresAtMs },
|
||||
{ ex: ttlSeconds },
|
||||
);
|
||||
if (purgeQueue && produced.key) {
|
||||
await redis.zadd(ns("purge", purgeQueue), {
|
||||
score: expiresAtMs,
|
||||
member: produced.key,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await captureServer(eventName, {
|
||||
...baseMetrics,
|
||||
...(produced.metrics ?? {}),
|
||||
duration_ms: Date.now() - startedAt,
|
||||
outcome: produced.url ? "ok" : "not_found",
|
||||
cache: "store",
|
||||
});
|
||||
return { url: produced.url };
|
||||
} finally {
|
||||
try {
|
||||
await redis.del(lockKey);
|
||||
} catch {}
|
||||
}
|
||||
}
|
138
lib/fetch.test.ts
Normal file
138
lib/fetch.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchWithTimeout, headThenGet } from "@/lib/fetch";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function createResponse(init: Partial<Response> = {}): Response {
|
||||
// Minimal Response-like object for our assertions
|
||||
return {
|
||||
ok: init.ok ?? true,
|
||||
status: init.status ?? 200,
|
||||
url: init.url ?? "https://example.com/",
|
||||
headers: init.headers ?? new Headers(),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob([]),
|
||||
formData: async () => new FormData(),
|
||||
json: async () => ({}),
|
||||
text: async () => "",
|
||||
redirected: false,
|
||||
statusText: "",
|
||||
type: "basic",
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
clone() {
|
||||
return createResponse(init);
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("lib/fetch", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves before timeout", async () => {
|
||||
const res = createResponse({ ok: true, status: 200 });
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => res as Response,
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const out = await fetchWithTimeout(
|
||||
"https://example.com",
|
||||
{},
|
||||
{ timeoutMs: 50 },
|
||||
);
|
||||
expect(out).toBe(res);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("aborts after timeout and rejects", async () => {
|
||||
// Use real timers to avoid PromiseRejectionHandledWarning with fake timers
|
||||
vi.useRealTimers();
|
||||
globalThis.fetch = vi.fn((_input, init) => {
|
||||
const signal = (init as RequestInit | undefined)?.signal as
|
||||
| AbortSignal
|
||||
| undefined;
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
}
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
fetchWithTimeout("https://slow.test", {}, { timeoutMs: 10 }),
|
||||
).rejects.toThrow("aborted");
|
||||
});
|
||||
|
||||
it("retries once and then succeeds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const res = createResponse({ ok: true, status: 200 });
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("network"))
|
||||
.mockResolvedValueOnce(res);
|
||||
globalThis.fetch = mock as unknown as typeof fetch;
|
||||
|
||||
const p = fetchWithTimeout(
|
||||
"https://flaky.test",
|
||||
{},
|
||||
{
|
||||
timeoutMs: 25,
|
||||
retries: 1,
|
||||
backoffMs: 5,
|
||||
},
|
||||
);
|
||||
// First attempt fails immediately; wait for backoff and second attempt
|
||||
await vi.runAllTimersAsync();
|
||||
const out = await p;
|
||||
expect(out).toBe(res);
|
||||
expect(mock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("headThenGet uses HEAD when ok", async () => {
|
||||
const res = createResponse({ ok: true, status: 200 });
|
||||
const mock = vi.fn(async (_input, init) => {
|
||||
if ((init as RequestInit | undefined)?.method === "HEAD") return res;
|
||||
return createResponse({ ok: false, status: 500 });
|
||||
});
|
||||
globalThis.fetch = mock as unknown as typeof fetch;
|
||||
|
||||
const { response, usedMethod } = await headThenGet(
|
||||
"https://example.com",
|
||||
{},
|
||||
{ timeoutMs: 50 },
|
||||
);
|
||||
expect(response).toBe(res);
|
||||
expect(usedMethod).toBe("HEAD");
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("headThenGet falls back to GET when HEAD fails", async () => {
|
||||
const getRes = createResponse({ ok: true, status: 200 });
|
||||
const mock = vi.fn(async (_input, init) => {
|
||||
if ((init as RequestInit | undefined)?.method === "HEAD")
|
||||
return createResponse({ ok: false, status: 405 });
|
||||
return getRes;
|
||||
});
|
||||
globalThis.fetch = mock as unknown as typeof fetch;
|
||||
|
||||
const { response, usedMethod } = await headThenGet(
|
||||
"https://example.com",
|
||||
{},
|
||||
{ timeoutMs: 50 },
|
||||
);
|
||||
expect(response).toBe(getRes);
|
||||
expect(usedMethod).toBe("GET");
|
||||
expect(mock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
127
lib/redis.ts
127
lib/redis.ts
@@ -3,130 +3,9 @@ import { Redis } from "@upstash/redis";
|
||||
// Uses KV_REST_API_URL and KV_REST_API_TOKEN set by Vercel integration
|
||||
export const redis = Redis.fromEnv();
|
||||
|
||||
/**
|
||||
* Return a single string from any number of strings joined by colons
|
||||
*/
|
||||
export function ns(...parts: string[]): string {
|
||||
return parts.join(":");
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export type LockResult<T = unknown> =
|
||||
| { acquired: true; cachedResult: null }
|
||||
| { acquired: false; cachedResult: T | null };
|
||||
|
||||
/**
|
||||
* Acquire a Redis lock or wait for result from another process
|
||||
*
|
||||
* This prevents duplicate work by:
|
||||
* 1. Trying to acquire lock with NX (only set if not exists)
|
||||
* 2. If lock acquired, caller does work and caches result
|
||||
* 3. If lock NOT acquired, poll for cached result until timeout
|
||||
*
|
||||
* @param options.lockKey - Redis key for the lock
|
||||
* @param options.resultKey - Redis key where result will be cached
|
||||
* @param options.lockTtl - Lock TTL in seconds (default 30)
|
||||
* @param options.pollIntervalMs - How often to check for result (default 250ms)
|
||||
* @param options.maxWaitMs - Max time to wait for result (default 25000ms)
|
||||
* @returns Lock status and any cached result
|
||||
*/
|
||||
export async function acquireLockOrWaitForResult<T = unknown>(options: {
|
||||
lockKey: string;
|
||||
resultKey: string;
|
||||
lockTtl?: number;
|
||||
pollIntervalMs?: number;
|
||||
maxWaitMs?: number;
|
||||
}): Promise<LockResult<T>> {
|
||||
const {
|
||||
lockKey,
|
||||
resultKey,
|
||||
lockTtl = 30,
|
||||
pollIntervalMs = 250,
|
||||
maxWaitMs = 25000,
|
||||
} = options;
|
||||
|
||||
// Try to acquire lock
|
||||
try {
|
||||
const setRes = await redis.set(lockKey, "1", {
|
||||
nx: true,
|
||||
ex: lockTtl,
|
||||
});
|
||||
const acquired = setRes === "OK" || setRes === undefined;
|
||||
|
||||
if (acquired) {
|
||||
console.debug("[redis] lock acquired", { lockKey });
|
||||
return { acquired: true, cachedResult: null };
|
||||
}
|
||||
|
||||
console.debug("[redis] lock not acquired, waiting for result", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
maxWaitMs,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[redis] lock acquisition failed", {
|
||||
lockKey,
|
||||
error: (err as Error)?.message,
|
||||
});
|
||||
// If Redis is down, fail open (don't wait)
|
||||
return { acquired: true, cachedResult: null };
|
||||
}
|
||||
|
||||
// Lock not acquired, poll for result
|
||||
const startTime = Date.now();
|
||||
let pollCount = 0;
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
pollCount++;
|
||||
const result = (await redis.get(resultKey)) as T | null;
|
||||
|
||||
if (result !== null) {
|
||||
console.debug("[redis] found cached result while waiting", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
pollCount,
|
||||
waitedMs: Date.now() - startTime,
|
||||
});
|
||||
return { acquired: false, cachedResult: result };
|
||||
}
|
||||
|
||||
// Check if lock still exists - if not, the other process may have failed
|
||||
const lockExists = await redis.exists(lockKey);
|
||||
if (!lockExists) {
|
||||
console.warn("[redis] lock disappeared without result", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
pollCount,
|
||||
});
|
||||
// Lock gone but no result - other process likely failed
|
||||
// Try to acquire lock ourselves
|
||||
const retryRes = await redis.set(lockKey, "1", {
|
||||
nx: true,
|
||||
ex: lockTtl,
|
||||
});
|
||||
const retryAcquired = retryRes === "OK" || retryRes === undefined;
|
||||
if (retryAcquired) {
|
||||
return { acquired: true, cachedResult: null };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[redis] error polling for result", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
error: (err as Error)?.message,
|
||||
});
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
console.warn("[redis] wait timeout, no result found", {
|
||||
lockKey,
|
||||
resultKey,
|
||||
pollCount,
|
||||
waitedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return { acquired: false, cachedResult: null };
|
||||
}
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import { captureServer } from "@/lib/analytics/server";
|
||||
import { acquireLockOrWaitForResult } from "@/lib/cache";
|
||||
import { isCloudflareIpAsync } from "@/lib/cloudflare";
|
||||
import { USER_AGENT } from "@/lib/constants";
|
||||
import { fetchWithTimeout } from "@/lib/fetch";
|
||||
import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis";
|
||||
import { ns, redis } from "@/lib/redis";
|
||||
import {
|
||||
type DnsRecord,
|
||||
type DnsResolveResult,
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { getOrCreateCachedAsset } from "@/lib/cache/cached-asset";
|
||||
import { getOrCreateCachedAsset } from "@/lib/cache";
|
||||
import { FAVICON_TTL_SECONDS, USER_AGENT } from "@/lib/constants";
|
||||
import { fetchWithTimeout } from "@/lib/fetch";
|
||||
import { convertBufferToImageCover } from "@/lib/image";
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { captureServer } from "@/lib/analytics/server";
|
||||
import { acquireLockOrWaitForResult } from "@/lib/cache";
|
||||
import { headThenGet } from "@/lib/fetch";
|
||||
import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis";
|
||||
import { ns, redis } from "@/lib/redis";
|
||||
import type { HttpHeader } from "@/lib/schemas";
|
||||
|
||||
export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis";
|
||||
import { acquireLockOrWaitForResult } from "@/lib/cache";
|
||||
import { ns, redis } from "@/lib/redis";
|
||||
import type { Pricing } from "@/lib/schemas";
|
||||
|
||||
type DomainPricingResponse = {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import type { Browser } from "puppeteer-core";
|
||||
import { getOrCreateCachedAsset } from "@/lib/cache/cached-asset";
|
||||
import { getOrCreateCachedAsset } from "@/lib/cache";
|
||||
import { SCREENSHOT_TTL_SECONDS, USER_AGENT } from "@/lib/constants";
|
||||
import { addWatermarkToScreenshot, optimizeImageCover } from "@/lib/image";
|
||||
import { launchChromium } from "@/lib/puppeteer";
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import { captureServer } from "@/lib/analytics/server";
|
||||
import { acquireLockOrWaitForResult } from "@/lib/cache";
|
||||
import { SOCIAL_PREVIEW_TTL_SECONDS, USER_AGENT } from "@/lib/constants";
|
||||
import { fetchWithTimeout } from "@/lib/fetch";
|
||||
import { optimizeImageCover } from "@/lib/image";
|
||||
import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis";
|
||||
import { ns, redis } from "@/lib/redis";
|
||||
import type { SeoResponse } from "@/lib/schemas";
|
||||
import { parseHtmlMeta, parseRobotsTxt, selectPreview } from "@/lib/seo";
|
||||
import { makeImageFileName, uploadImage } from "@/lib/storage";
|
||||
|
@@ -1,10 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
isServer,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { isServer, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import {
|
||||
createTRPCClient,
|
||||
@@ -15,27 +11,13 @@ import { useState } from "react";
|
||||
import superjson from "superjson";
|
||||
import { TRPCProvider as Provider } from "@/lib/trpc/client";
|
||||
import type { AppRouter } from "@/server/routers/_app";
|
||||
import { getQueryClient } from "@/trpc/query-client";
|
||||
|
||||
let browserQueryClient: QueryClient | undefined;
|
||||
let browserQueryClient: ReturnType<typeof getQueryClient> | undefined;
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Avoid immediate refetch after hydration
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 60 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getQueryClient() {
|
||||
if (isServer) return makeQueryClient();
|
||||
if (!browserQueryClient) browserQueryClient = makeQueryClient();
|
||||
function getStableQueryClient() {
|
||||
if (isServer) return getQueryClient();
|
||||
if (!browserQueryClient) browserQueryClient = getQueryClient();
|
||||
return browserQueryClient;
|
||||
}
|
||||
|
||||
@@ -46,7 +28,7 @@ const getBaseUrl = () => {
|
||||
};
|
||||
|
||||
export function TRPCProvider({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = getQueryClient();
|
||||
const queryClient = getStableQueryClient();
|
||||
const [trpcClient] = useState(() =>
|
||||
createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
|
@@ -9,7 +9,10 @@ function makeQueryClient() {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Avoid immediate client refetch after hydration
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 5 * 60_000, // 5 minutes
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
dehydrate: {
|
||||
// Include pending queries so streaming works smoothly
|
||||
|
Reference in New Issue
Block a user