1
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:
2025-10-14 19:21:31 -04:00
parent 43a73a5e27
commit a14349e366
18 changed files with 514 additions and 403 deletions

View File

@@ -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,
},
),
);

View File

@@ -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,
},
),
);

View File

@@ -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,
},
),
);

View File

@@ -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}
/>
);
}

View File

@@ -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
View 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
View 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 {}
}
}

View File

@@ -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
View 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);
});
});

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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[]> {

View File

@@ -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 = {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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: [

View File

@@ -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