Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/api/cache/warm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getProjectRegistryState,
refreshProjectRedirectMap,
registryEntryForProject,
syncProjectRegistry,
} from "@/lib/projectRegistry";

/**
Expand All @@ -22,6 +23,14 @@ import {
* Upstash key. Expiring tags here would force a re-render every five minutes
* for no freshness gain.
*
* It DOES run the registry sync: `/api/nostr/refresh`'s `after()` hook is the
* only other trigger, and clients call that route only when they spot an event
* NEWER than the server snapshot — which this cron keeps fresh, starving that
* trigger. Without the sync here, new projects never get slugs (the registry
* once sat unpublished for days). `syncProjectRegistry` is fully self-guarded
* (env gates, 5-min throttle, write lock, append-only fresh-read checks) and
* no-ops when there is nothing to register.
*
* Scheduled from `vercel.json`. Vercel Cron sends `Authorization: Bearer
* $CRON_SECRET` automatically once CRON_SECRET is set on the project. Hobby
* projects only get daily crons — on that plan, drive this from an Upstash
Expand Down Expand Up @@ -78,11 +87,16 @@ export async function GET(req: NextRequest) {
// middleware 308s legacy `/projects/<id>` URLs even without a registration.
await refreshProjectRedirectMap();

// Register any new projects (assign slugs + republish the registry event).
// Self-guarded and swallow-on-error — a failed attempt retries next tick.
await syncProjectRegistry();

return NextResponse.json({
ok: true,
warmed: snapshot.projects.length > 0,
projects: snapshot.projects.length,
durableWrites,
registrySyncAttempted: true,
generatedAt: snapshot.generatedAt,
elapsedMs: Date.now() - startedAt,
});
Expand Down
16 changes: 16 additions & 0 deletions app/dashboard/projects/UserProjectsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
fetchUserProjects,
getCachedUserProjects,
publishUserProject,
refreshNostrServerCache,
removeCachedCommunityProject,
upsertCachedCommunityProject,
type ProjectsDoc,
Expand Down Expand Up @@ -441,6 +442,21 @@ export default function UserProjectsClient() {
upsertCachedCommunityProject(
communityProjectFromSignedEvent(project, result.signed),
);
// Read-your-writes: warm the server snapshot / per-id caches with the
// event we just published (its `after()` hook also triggers the registry
// sync), so the public `/projects/<id>` URL resolves immediately instead
// of waiting for the next cron scan. Fire-and-forget — the relays
// already accepted the event, so a refresh hiccup must not fail the save.
refreshNostrServerCache({
scopes: ["projects"],
projectId: project.id,
author: result.signed.pubkey,
candidateEventId: result.signed.id,
candidateCreatedAt: result.signed.created_at,
blocking: true,
}).catch((e) => {
console.warn("[labs] post-publish server refresh failed", e);
});
setTimeout(() => setPublishProgress(null), 3500);
return result;
} catch (e) {
Expand Down
28 changes: 27 additions & 1 deletion app/projects/[slug]/NostrProjectPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ function FeedbackCard({
* narrows relay lookups when the caller knows it (registry-backed URLs).
*/
export default function NostrProjectPage({
projectId,
projectId: projectIdProp,
author,
canonicalSlug,
initialProject,
Expand All @@ -500,6 +500,27 @@ export default function NostrProjectPage({
canonicalSlug?: string;
initialProject?: CommunityProject;
}) {
// The build-time fallback shell of `/projects/[slug]` is prerendered with the
// literal route placeholder as the param, so a stale cached shell arrives
// here with projectId === "[slug]". The URL is the only trustworthy source in
// that case — and it must be window.location, not useParams(), because the
// client router hydrates from the same placeholder RSC tree. Start from the
// server-passed prop (keeps hydration consistent) and swap in the real path
// segment after mount.
const [projectId, setProjectId] = useState(projectIdProp);
useEffect(() => {
if (projectIdProp !== "[slug]") {
setProjectId(projectIdProp);
return;
}
const last = window.location.pathname.split("/").filter(Boolean).pop();
if (!last) return;
try {
setProjectId(decodeURIComponent(last));
} catch {
/* malformed escape — keep the placeholder */
}
}, [projectIdProp]);
const [project, setProject] = useState<CommunityProject | null | undefined>(
initialProject ?? undefined,
);
Expand Down Expand Up @@ -527,6 +548,11 @@ export default function NostrProjectPage({
}, [auth]);

useEffect(() => {
// Placeholder param from a stale fallback shell — the rescue effect above
// swaps in the real URL segment on the next render; scanning for the
// literal "[slug]" would only ever end in "Proyecto no encontrado".
if (projectId === "[slug]") return;

let cancelled = false;
const snapshotAbort = new AbortController();

Expand Down
10 changes: 10 additions & 0 deletions app/projects/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { connection } from "next/server";
import { permanentRedirect } from "next/navigation";
import {
getCanonicalProjectRefs,
Expand Down Expand Up @@ -119,6 +120,15 @@ async function PageContent({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
if (slug === "[slug]") {
// Fallback-shell prerender: the build renders this route once with the
// literal template placeholder as the param. Left alone it would bake a
// fully-resolved "unknown project" client scan into the static shell —
// which is then served (and can get pinned) for params that have no cached
// render yet. Postpone instead, so the shell suspends at <ProjectFallback>
// and real params always resolve at request time.
await connection();
}
const resolved = await resolveProjectParam(slug);

if (resolved.kind === "pubkey") {
Expand Down
4 changes: 4 additions & 0 deletions lib/nostrRelayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const LACRYPTA_DEFAULT_RELAYS = [
] as const;

export const LACRYPTA_FAST_USER_RELAYS = [
// La Crypta's own relay must always hold community events — the server-side
// snapshot/registry reads include it, and a publish that lands only on the
// third-party relays leaves the event thinly propagated.
"wss://relay.lacrypta.ar",
"wss://relay.masize.com",
"wss://relay.damus.io",
"wss://relay.primal.net",
Expand Down
38 changes: 34 additions & 4 deletions lib/projectResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import {
getNostrSubmissionsSnapshot,
getProjectWithDurableFallback,
type CachedNostrProject,
type CachedNostrSubmissionsSnapshot,
} from "./nostrCache";
import {
buildRegistryState,
getProjectRegistryState,
registryEntryForProject,
type ProjectRegistryState,
Expand All @@ -40,6 +42,25 @@ const HEX64_RE = /^[0-9a-f]{64}$/;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

/** Resolve to `fallback` if `promise` hasn't settled within `ms`. */
async function withResolverDeadline<T>(
promise: Promise<T>,
ms: number,
fallback: T,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise.catch(() => fallback),
new Promise<T>((resolve) => {
timer = setTimeout(() => resolve(fallback), ms);
}),
]);
} finally {
clearTimeout(timer);
}
}

export type ResolvedProject = {
kind: "project";
canonicalSlug: string;
Expand Down Expand Up @@ -211,10 +232,19 @@ export async function resolveProjectParam(
if (pubkey) return { kind: "pubkey", pubkey };
}

const [registry, snapshot] = await Promise.all([
getProjectRegistryState(),
getNostrSubmissionsSnapshot(),
]);
// The cached round-trips are internally time-capped, but this render path
// must NEVER hang: a page stream that stalls here is what pins Vercel's
// prerender cache on a stale fallback shell (revalidation only replaces an
// entry after a *successful* render). Degrade to empty state — the page then
// falls through to the client-scan shell instead of hanging.
const [registry, snapshot] = await withResolverDeadline(
Promise.all([getProjectRegistryState(), getNostrSubmissionsSnapshot()]),
10_000,
[
buildRegistryState([]),
{ projects: [], generatedAt: "", relays: [] },
] as [ProjectRegistryState, CachedNostrSubmissionsSnapshot],
);

// 1. Registry slug (the canonical namespace). The matched slug may be an
// alias (a project's earlier slug, kept as a redirect after the owner changed
Expand Down
62 changes: 52 additions & 10 deletions lib/upstashCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,45 @@ function buildClient(): Redis | null {
if (!url || !token) return null;
if (process.env.UPSTASH_CACHE_DISABLED === "1") return null;
try {
return new Redis({ url, token });
// The default client retries 5× with backoff and has no request timeout;
// paired with the per-call deadline below, a struggling endpoint must
// degrade to a cache miss, never stall a render.
return new Redis({
url,
token,
retry: {
retries: 2,
backoff: (retryCount) => Math.min(1000, 100 * 2 ** retryCount),
},
});
} catch {
return null;
}
}

/**
* Hard per-call deadline. Every render on the site funnels through this module
* (snapshot, registry, redirect map, durable copies), so an unbounded Upstash
* call would hang page streams and ISR revalidations fleet-wide — the timeout
* makes a slow endpoint indistinguishable from an unconfigured one.
*/
const READ_TIMEOUT_MS = 2000;
const WRITE_TIMEOUT_MS = 3000;

async function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("upstash deadline")), ms);
}),
]);
} finally {
clearTimeout(timer);
}
}

/** True when reads/writes will actually reach Upstash. Useful for warm routes. */
export function isUpstashEnabled(): boolean {
return getRedis() !== null;
Expand Down Expand Up @@ -170,7 +203,7 @@ export async function upstashReadThrough<T>(
const fullKey = namespacedKey(key);

try {
const hit = await redis.get<T>(fullKey);
const hit = await withDeadline(redis.get<T>(fullKey), READ_TIMEOUT_MS);
if (hit !== null && hit !== undefined) return hit;
} catch {
return producer();
Expand All @@ -181,7 +214,10 @@ export async function upstashReadThrough<T>(
options?.shouldCache ?? ((v: T) => v !== null && v !== undefined);
if (shouldCache(value)) {
try {
await redis.set(fullKey, value, { ex: ttlSeconds });
await withDeadline(
redis.set(fullKey, value, { ex: ttlSeconds }),
WRITE_TIMEOUT_MS,
);
} catch {
/* best-effort write; the value is already on its way to the caller */
}
Expand All @@ -199,7 +235,10 @@ export async function upstashGet<T>(key: string): Promise<T | null> {
const redis = getRedis();
if (!redis) return null;
try {
const hit = await redis.get<T>(namespacedKey(key));
const hit = await withDeadline(
redis.get<T>(namespacedKey(key)),
READ_TIMEOUT_MS,
);
return hit ?? null;
} catch {
return null;
Expand All @@ -219,7 +258,10 @@ export async function upstashSet<T>(
const redis = getRedis();
if (!redis) return;
try {
await redis.set(namespacedKey(key), value, { ex: ttlSeconds });
await withDeadline(
redis.set(namespacedKey(key), value, { ex: ttlSeconds }),
WRITE_TIMEOUT_MS,
);
} catch {
/* best-effort */
}
Expand All @@ -243,10 +285,10 @@ export async function upstashAcquireLock(
const redis = getRedis();
if (!redis) return true;
try {
const res = await redis.set(namespacedKey(key), "1", {
nx: true,
ex: ttlSeconds,
});
const res = await withDeadline(
redis.set(namespacedKey(key), "1", { nx: true, ex: ttlSeconds }),
WRITE_TIMEOUT_MS,
);
return res === "OK";
} catch {
return true;
Expand All @@ -263,7 +305,7 @@ export async function upstashDelete(...keys: string[]): Promise<void> {
const redis = getRedis();
if (!redis || keys.length === 0) return;
try {
await redis.del(...keys.map(namespacedKey));
await withDeadline(redis.del(...keys.map(namespacedKey)), WRITE_TIMEOUT_MS);
} catch {
/* best-effort */
}
Expand Down