From f32938cff2d3974ff9be69b055854a5e07a02c26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 14:05:13 +0000 Subject: [PATCH] Emit HTTP 308 for published slug aliases before HTML. generateMetadata returned Not found + noindex and the root layout started the document, so page-level permanentRedirect could only stream a meta refresh. Look up the alias in generateMetadata and return NextResponse 308 from proxy before render. Co-authored-by: Chinmay Kabi --- src/app/g/[slug]/page.tsx | 16 ++- .../__tests__/published-slug-alias.test.ts | 109 ++++++++++++++++++ src/lib/public-generation.ts | 39 ------- src/lib/published-slug-alias.ts | 91 +++++++++++++++ src/proxy.ts | 27 +++++ 5 files changed, 239 insertions(+), 43 deletions(-) create mode 100644 src/lib/__tests__/published-slug-alias.test.ts create mode 100644 src/lib/published-slug-alias.ts diff --git a/src/app/g/[slug]/page.tsx b/src/app/g/[slug]/page.tsx index a65c446..7394a66 100644 --- a/src/app/g/[slug]/page.tsx +++ b/src/app/g/[slug]/page.tsx @@ -4,10 +4,11 @@ import { preload } from "react-dom"; import { fetchFeedServer } from "@/lib/fetch-feed"; import { OG_IMAGE_PIXEL_SIZE } from "@/lib/generation-media-url"; +import { getGenerationBySlugCached } from "@/lib/public-generation"; import { findPublishedSlugAlias, - getGenerationBySlugCached, -} from "@/lib/public-generation"; + publishedGenerationPath, +} from "@/lib/published-slug-alias"; import { formatOgDescription, formatResultTitle } from "@/lib/ui/format"; import type { FeedItem } from "@/lib/ui/types"; @@ -15,11 +16,19 @@ import { GenerationDetailClient } from "./generation-detail-client"; type Props = { params: Promise<{ slug: string }> }; +async function redirectToPublishedSlugAlias(slug: string): Promise { + const alias = await findPublishedSlugAlias(slug); + if (alias) permanentRedirect(publishedGenerationPath(alias)); +} + export async function generateMetadata({ params }: Props): Promise { const { slug } = await params; const gen = await getGenerationBySlugCached(slug); if (!gen) { + // Must throw before this metadata is committed; otherwise the response is + // already 200 HTML ("Not found" + noindex) and permanentRedirect streams. + await redirectToPublishedSlugAlias(slug); return { title: "Not found", robots: { index: false, follow: false }, @@ -73,8 +82,7 @@ export default async function GenerationDetailPage({ params }: Props) { const { slug } = await params; const gen = await getGenerationBySlugCached(slug); if (!gen) { - const alias = await findPublishedSlugAlias(slug); - if (alias) permanentRedirect(`/g/${encodeURIComponent(alias)}`); + await redirectToPublishedSlugAlias(slug); notFound(); } diff --git a/src/lib/__tests__/published-slug-alias.test.ts b/src/lib/__tests__/published-slug-alias.test.ts new file mode 100644 index 0000000..574e114 --- /dev/null +++ b/src/lib/__tests__/published-slug-alias.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + generationDetailSlugFromPathname, + publishedGenerationPath, + resolvePublishedSlugAliasRedirect, +} from "@/lib/published-slug-alias"; + +const { createPublicFeedClient } = vi.hoisted(() => ({ + createPublicFeedClient: vi.fn(), +})); + +vi.mock("@/lib/feed-client", () => ({ + createPublicFeedClient, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function mockGenerationsQuery( + results: Array<{ data: unknown; error: { message: string } | null }>, +) { + const query: { + eq: ReturnType; + in: ReturnType; + like: ReturnType; + limit: ReturnType; + } = { + eq: vi.fn(), + in: vi.fn(), + like: vi.fn(), + limit: vi.fn(), + }; + query.eq.mockReturnValue(query); + query.in.mockReturnValue(query); + query.like.mockReturnValue(query); + for (const result of results) { + query.limit.mockResolvedValueOnce(result); + } + createPublicFeedClient.mockReturnValue({ + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue(query), + }), + }); + return query; +} + +describe("generationDetailSlugFromPathname", () => { + it("reads a single /g/{slug} segment", () => { + expect(generationDetailSlugFromPathname("/g/microsoft-teams-tinder")).toBe( + "microsoft-teams-tinder", + ); + expect( + generationDetailSlugFromPathname("/g/microsoft-teams-tinder/"), + ).toBe("microsoft-teams-tinder"); + expect(generationDetailSlugFromPathname("/g/foo/bar")).toBeNull(); + expect(generationDetailSlugFromPathname("/feed")).toBeNull(); + }); +}); + +describe("publishedGenerationPath", () => { + it("encodes the alias path", () => { + expect(publishedGenerationPath("microsoft-teams-tinder-1")).toBe( + "/g/microsoft-teams-tinder-1", + ); + }); +}); + +describe("resolvePublishedSlugAliasRedirect", () => { + it("returns the lowest numeric alias when the exact slug is not published", async () => { + mockGenerationsQuery([ + { data: [], error: null }, + { + data: [ + { slug: "microsoft-teams-tinder-2" }, + { slug: "microsoft-teams-tinder-1" }, + { slug: "microsoft-teams-tinder-box" }, + ], + error: null, + }, + ]); + + await expect( + resolvePublishedSlugAliasRedirect("microsoft-teams-tinder"), + ).resolves.toBe("microsoft-teams-tinder-1"); + }); + + it("does not redirect when the exact slug is already a published generation", async () => { + mockGenerationsQuery([ + { data: [{ slug: "youtube-figma" }], error: null }, + ]); + + await expect( + resolvePublishedSlugAliasRedirect("youtube-figma"), + ).resolves.toBeNull(); + }); + + it("returns null when no numeric alias exists", async () => { + mockGenerationsQuery([ + { data: [], error: null }, + { data: [], error: null }, + ]); + + await expect( + resolvePublishedSlugAliasRedirect("no-such-mashup"), + ).resolves.toBeNull(); + }); +}); diff --git a/src/lib/public-generation.ts b/src/lib/public-generation.ts index 8048521..30da961 100644 --- a/src/lib/public-generation.ts +++ b/src/lib/public-generation.ts @@ -1,10 +1,7 @@ import { getAnonSessionId } from "@/lib/anon-session"; -import { createPublicFeedClient } from "@/lib/feed-client"; -import { publicGenerationsQuery } from "@/lib/feed-public-filters"; import { generationImageUrl } from "@/lib/generation-media-url"; import type { GenerationStatus } from "@/lib/generation/types"; import { isGenerationStatus } from "@/lib/generation/types"; -import { isNumericSlugAlias } from "@/lib/slug"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { cache } from "react"; @@ -259,42 +256,6 @@ export async function getGenerationBySlug( */ export const getGenerationBySlugCached = cache(getGenerationBySlug); -/** - * If `/g/{slug}` is not a public generation, find a published numeric alias - * (`{slug}-1`) so dead uniqueness-suffix paths can redirect. - */ -export async function findPublishedSlugAlias( - requested: string, -): Promise { - const trimmed = requested.trim(); - if (!trimmed) return null; - - const supabase = createPublicFeedClient(); - if (!supabase) return null; - - try { - const { data, error } = await publicGenerationsQuery(supabase, "slug") - .like("slug", `${trimmed}-%`) - .limit(50); - - if (error || !data) return null; - - const matches = (data as { slug: unknown }[]) - .map((row) => row.slug) - .filter((slug): slug is string => typeof slug === "string") - .filter((slug) => isNumericSlugAlias(trimmed, slug) && slug !== trimmed) - .sort((a, b) => { - const aN = Number(a.slice(trimmed.length + 1)); - const bN = Number(b.slice(trimmed.length + 1)); - return aN - bN; - }); - - return matches[0] ?? null; - } catch { - return null; - } -} - /** @deprecated Use getGenerationBySlug */ export async function getPublishedGenerationBySlug( slug: string, diff --git a/src/lib/published-slug-alias.ts b/src/lib/published-slug-alias.ts new file mode 100644 index 0000000..10cd251 --- /dev/null +++ b/src/lib/published-slug-alias.ts @@ -0,0 +1,91 @@ +import { createPublicFeedClient } from "@/lib/feed-client"; +import { publicGenerationsQuery } from "@/lib/feed-public-filters"; +import { isNumericSlugAlias } from "@/lib/slug"; + +/** `/g/{slug}` with a single path segment, or null. */ +export function generationDetailSlugFromPathname( + pathname: string, +): string | null { + const match = /^\/g\/([^/]+)\/?$/.exec(pathname); + if (!match?.[1]) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; + } +} + +export function publishedGenerationPath(slug: string): string { + return `/g/${encodeURIComponent(slug)}`; +} + +/** + * If `/g/{slug}` is not a public generation, find a published numeric alias + * (`{slug}-1`) so dead uniqueness-suffix paths can redirect. + */ +export async function findPublishedSlugAlias( + requested: string, +): Promise { + const trimmed = requested.trim(); + if (!trimmed) return null; + + const supabase = createPublicFeedClient(); + if (!supabase) return null; + + try { + const { data, error } = await publicGenerationsQuery(supabase, "slug") + .like("slug", `${trimmed}-%`) + .limit(50); + + if (error || !data) return null; + + const matches = (data as { slug: unknown }[]) + .map((row) => row.slug) + .filter((slug): slug is string => typeof slug === "string") + .filter((slug) => isNumericSlugAlias(trimmed, slug) && slug !== trimmed) + .sort((a, b) => { + const aN = Number(a.slice(trimmed.length + 1)); + const bN = Number(b.slice(trimmed.length + 1)); + return aN - bN; + }); + + return matches[0] ?? null; + } catch { + return null; + } +} + +async function hasPublishedGenerationSlug(slug: string): Promise { + const trimmed = slug.trim(); + if (!trimmed) return false; + + const supabase = createPublicFeedClient(); + if (!supabase) return false; + + try { + const { data, error } = await publicGenerationsQuery(supabase, "slug") + .in("slug", [trimmed]) + .limit(1); + + if (error || !data) return false; + + return (data as { slug: unknown }[]).some( + (row) => typeof row.slug === "string" && row.slug === trimmed, + ); + } catch { + return false; + } +} + +/** + * Alias to 308 toward when `{slug}` itself is not a published generation. + * Returns null when the exact slug is live, so `/g/foo` is not sent to `/g/foo-1`. + */ +export async function resolvePublishedSlugAliasRedirect( + requested: string, +): Promise { + const trimmed = requested.trim(); + if (!trimmed) return null; + if (await hasPublishedGenerationSlug(trimmed)) return null; + return findPublishedSlugAlias(trimmed); +} diff --git a/src/proxy.ts b/src/proxy.ts index 42ea196..c5e7b1b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,14 +1,41 @@ import { createServerClient } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; +import { + generationDetailSlugFromPathname, + publishedGenerationPath, + resolvePublishedSlugAliasRedirect, +} from "@/lib/published-slug-alias"; import { hasSupabaseAuthCookie } from "@/lib/supabase/auth-cookie"; import { tryGetSupabasePublicEnv } from "@/lib/supabase/public-env"; +/** + * HTTP 308 before any HTML. `permanentRedirect` in the page runs after + * generateMetadata / the root layout have already started the document, so + * crawlers that only read the status line saw 200 + "Not found" + noindex. + */ +async function publishedSlugAliasRedirect( + request: NextRequest, +): Promise { + const slug = generationDetailSlugFromPathname(request.nextUrl.pathname); + if (!slug) return null; + + const alias = await resolvePublishedSlugAliasRedirect(slug); + if (!alias) return null; + + const url = request.nextUrl.clone(); + url.pathname = publishedGenerationPath(alias); + return NextResponse.redirect(url, 308); +} + /** * Refreshes Supabase Auth cookies before render (Next.js 16+ proxy convention). * @see https://supabase.com/docs/guides/auth/server-side/nextjs */ export async function proxy(request: NextRequest) { + const aliasRedirect = await publishedSlugAliasRedirect(request); + if (aliasRedirect) return aliasRedirect; + if (!hasSupabaseAuthCookie(request.cookies.getAll())) { return NextResponse.next({ request }); }