Files
cloudflare__vinext/tests/metadata-route-response.test.ts
T
Nathan Nguyen 1551f91cbd fix(metadata): match Next dynamic route semantics (#1317)
* fix(metadata): match Next dynamic route semantics

Metadata routes diverged from Next.js in several observable cases: robots.txt emitted Sitemap before Host, dynamic metadata Response results could miss framework cache headers, TSX sitemap files were not discovered, and rendered metadata URLs treated canonical, manifest, and file-based social image routes as the same URL class.

The implementation now follows Next's metadata route extension, cache, robots, and URL resolution rules while keeping file-based social image route markers non-enumerable so resolved metadata shape does not leak internal state.

* fix(metadata): gate social image preview fallback

File-based social image routes used the preview deployment URL whenever VERCEL_URL was present outside development. That let deployment URLs override an explicit metadataBase in non-preview production environments.

Gate preview URL fallback on VERCEL_ENV=preview, add coverage for metadataBase precedence and production fallback URLs, and keep static metadata image cache headers stable in development.
2026-05-19 14:11:47 +01:00

467 lines
15 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import { handleMetadataRouteRequest } from "../packages/vinext/src/server/metadata-route-response.js";
import type { MetadataFileRoute } from "../packages/vinext/src/server/metadata-routes.js";
import { withEnvVar } from "./env-test-helpers.js";
type MetadataRuntimeRoute = MetadataFileRoute & {
fileDataBase64?: string;
};
function makeThenableParams(params: Record<string, string | string[]>): unknown {
return Object.assign(Promise.resolve(params), params);
}
describe("handleMetadataRouteRequest", () => {
it("does not inspect generateSitemaps on non-sitemap metadata routes", async () => {
let generateSitemapsReads = 0;
const route = {
type: "icon",
isDynamic: true,
filePath: "/tmp/app/icon.tsx",
routePrefix: "",
routeSegments: [],
servedUrl: "/icon",
contentType: "image/png",
module: {
get generateSitemaps() {
generateSitemapsReads++;
return () => [];
},
default: () => new Response("icon", { headers: { "Content-Type": "image/png" } }),
},
} satisfies MetadataFileRoute;
const response = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/icon",
makeThenableParams,
});
expect(response?.status).toBe(200);
expect(generateSitemapsReads).toBe(0);
});
it("serves matched static metadata route file data", async () => {
const route = {
type: "icon",
isDynamic: false,
filePath: "/tmp/app/icon.png",
routePrefix: "",
routeSegments: [],
servedUrl: "/icon.png",
contentType: "image/png",
fileDataBase64: btoa("icon-bytes"),
} satisfies MetadataRuntimeRoute;
const response = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/icon.png",
makeThenableParams,
});
expect(response?.status).toBe(200);
expect(response?.headers.get("content-type")).toBe("image/png");
expect(response?.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
expect(
Array.from(new Uint8Array((await response?.arrayBuffer()) ?? new ArrayBuffer(0))),
).toEqual([105, 99, 111, 110, 45, 98, 121, 116, 101, 115]);
});
it("keeps static image metadata route cache control stable in development", async () => {
await withEnvVar("NODE_ENV", "development", async () => {
const route = {
type: "apple-icon",
isDynamic: false,
filePath: "/tmp/app/apple-icon.png",
routePrefix: "",
routeSegments: [],
servedUrl: "/apple-icon.png",
contentType: "image/png",
fileDataBase64: btoa("icon-bytes"),
} satisfies MetadataRuntimeRoute;
const response = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/apple-icon.png",
makeThenableParams,
});
expect(response?.status).toBe(200);
expect(response?.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
});
});
it("caches metadata route module function lookups", async () => {
let generateImageMetadataReads = 0;
const route = {
type: "icon",
isDynamic: true,
filePath: "/tmp/app/icon.tsx",
routePrefix: "",
routeSegments: [],
servedUrl: "/icon",
contentType: "image/png",
module: {
get generateImageMetadata() {
generateImageMetadataReads++;
return () => [{ id: "small" }];
},
default: () => new Response("icon"),
},
} satisfies MetadataFileRoute;
const firstResponse = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/icon/small",
makeThenableParams,
});
const secondResponse = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/icon/small",
makeThenableParams,
});
expect(firstResponse?.status).toBe(200);
expect(secondResponse?.status).toBe(200);
expect(generateImageMetadataReads).toBe(1);
});
it("checks generateSitemaps once when skipping the generated sitemap base URL", async () => {
let generateSitemapsReads = 0;
const route = {
type: "sitemap",
isDynamic: true,
filePath: "/tmp/app/products/sitemap.ts",
routePrefix: "/products",
routeSegments: ["products"],
servedUrl: "/products/sitemap.xml",
contentType: "application/xml",
module: {
get generateSitemaps() {
generateSitemapsReads++;
return () => [{ id: 0 }];
},
default: () => [{ url: "https://example.com/products/0" }],
},
} satisfies MetadataFileRoute;
const response = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/products/sitemap.xml",
makeThenableParams,
});
expect(response).toBeNull();
expect(generateSitemapsReads).toBe(1);
});
it("passes generated sitemap id as a thenable URL string id", async () => {
let receivedPromise = false;
let receivedSyncId: string | undefined;
let receivedPrimitiveId: string | undefined;
const route = {
type: "sitemap",
isDynamic: true,
filePath: "/tmp/app/products/sitemap.ts",
routePrefix: "/products",
routeSegments: ["products"],
servedUrl: "/products/sitemap.xml",
contentType: "application/xml",
module: {
generateSitemaps: () => [{ id: 0 }],
default: async ({
id,
}: {
id: Promise<string | undefined> & {
toString(): string;
[Symbol.toPrimitive](): string;
};
}) => {
receivedPromise = id instanceof Promise;
receivedSyncId = id.toString();
receivedPrimitiveId = String(id);
return [{ url: `https://example.com/products/${await id}` }];
},
},
} satisfies MetadataFileRoute;
const response = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/products/sitemap/0.xml",
makeThenableParams,
});
expect(response?.status).toBe(200);
expect(receivedPromise).toBe(true);
expect(receivedSyncId).toBe("0");
expect(receivedPrimitiveId).toBe("0");
expect(response?.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
expect(await response?.text()).toContain("https://example.com/products/0");
});
it("throws when matched static metadata route data is missing", async () => {
const route = {
type: "icon",
isDynamic: false,
filePath: "/tmp/app/icon.png",
routePrefix: "",
routeSegments: [],
servedUrl: "/icon.png",
contentType: "image/png",
} satisfies MetadataFileRoute;
await expect(
handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/icon.png",
makeThenableParams,
}),
).rejects.toThrow("Static metadata route /icon.png is missing embedded file data");
});
it("throws when matched static metadata route data is corrupt", async () => {
const route = {
type: "icon",
isDynamic: false,
filePath: "/tmp/app/icon.png",
routePrefix: "",
routeSegments: [],
servedUrl: "/icon.png",
contentType: "image/png",
fileDataBase64: "%%%",
} satisfies MetadataRuntimeRoute;
await expect(
handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/icon.png",
makeThenableParams,
}),
).rejects.toThrow("Failed to decode embedded metadata route file data for /icon.png");
});
it("sets explicit cache control on generated metadata route responses", async () => {
const route = {
type: "robots",
isDynamic: true,
filePath: "/tmp/app/robots.ts",
routePrefix: "",
routeSegments: [],
servedUrl: "/robots.txt",
contentType: "text/plain",
module: {
default: () => ({ rules: { userAgent: "*" } }),
},
} satisfies MetadataFileRoute;
const response = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/robots.txt",
makeThenableParams,
});
expect(response?.status).toBe(200);
expect(response?.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
});
it("throws the route contract error when robots returns an array", async () => {
const route = {
type: "robots",
isDynamic: true,
filePath: "/tmp/app/robots.ts",
routePrefix: "",
routeSegments: [],
servedUrl: "/robots.txt",
contentType: "text/plain",
module: {
default: () => [],
},
} satisfies MetadataFileRoute;
await expect(
handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/robots.txt",
makeThenableParams,
}),
).rejects.toThrow("Metadata robots routes must return an object.");
});
it("throws the route contract error when manifest returns an array", async () => {
const route = {
type: "manifest",
isDynamic: true,
filePath: "/tmp/app/manifest.ts",
routePrefix: "",
routeSegments: [],
servedUrl: "/manifest.webmanifest",
contentType: "application/manifest+json",
module: {
default: () => [],
},
} satisfies MetadataFileRoute;
await expect(
handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/manifest.webmanifest",
makeThenableParams,
}),
).rejects.toThrow("Metadata manifest routes must return an object.");
});
it("throws when generateSitemaps returns an entry without id", async () => {
const route = {
type: "sitemap",
isDynamic: true,
filePath: "/tmp/app/products/sitemap.ts",
routePrefix: "/products",
routeSegments: ["products"],
servedUrl: "/products/sitemap.xml",
contentType: "application/xml",
module: {
generateSitemaps: () => [{}],
default: () => [{ url: "https://example.com/products/0" }],
},
} satisfies MetadataFileRoute;
await expect(
handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/products/sitemap/0.xml",
makeThenableParams,
}),
).rejects.toThrow("id property is required for every item returned from generateSitemaps");
});
it("serves dynamic generated image metadata routes by matched id", async () => {
let receivedId: Promise<string | undefined> | null = null;
let receivedSyncId: string | undefined;
let receivedSlug: string | undefined;
const route = {
type: "opengraph-image",
isDynamic: true,
filePath: "/tmp/app/blog/[slug]/opengraph-image.tsx",
routePrefix: "/blog/[slug]",
routeSegments: ["blog", "[slug]"],
servedUrl: "/blog/[slug]/opengraph-image",
patternParts: ["blog", ":slug", "opengraph-image"],
contentType: "image/png",
module: {
generateImageMetadata: async ({ params }: { params: Promise<{ slug: string }> }) => [
{ id: `${(await params).slug}-small` },
],
default: async ({
id,
params,
}: {
id: Promise<string | undefined> & { toString(): string };
params: Promise<{ slug: string }> & { slug?: string };
}) => {
receivedId = id;
receivedSyncId = id.toString();
receivedSlug = params.slug;
return new Response(`image:${await id}`, {
headers: { "Content-Type": "image/png" },
});
},
},
} satisfies MetadataFileRoute;
const response = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/blog/post/opengraph-image/post-small",
makeThenableParams,
});
expect(response?.status).toBe(200);
expect(response?.headers.get("content-type")).toBe("image/png");
expect(receivedId).toBeInstanceOf(Promise);
expect(receivedSyncId).toBe("post-small");
expect(receivedSlug).toBe("post");
expect(await response?.text()).toBe("image:post-small");
});
it("sets metadata cache control on dynamic image route Response results", async () => {
// Ported from Next.js: test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
const route = {
type: "opengraph-image",
isDynamic: true,
filePath: "/tmp/app/opengraph-image.tsx",
routePrefix: "",
routeSegments: [],
servedUrl: "/opengraph-image",
contentType: "image/png",
module: {
default: () => new Response("image", { headers: { "Content-Type": "image/png" } }),
},
} satisfies MetadataFileRoute;
const response = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/opengraph-image",
makeThenableParams,
});
expect(response?.status).toBe(200);
expect(response?.headers.get("content-type")).toBe("image/png");
expect(response?.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
});
it("returns 404 for unknown or invalid generated image ids", async () => {
const route = {
type: "icon",
isDynamic: true,
filePath: "/tmp/app/icon.tsx",
routePrefix: "",
routeSegments: [],
servedUrl: "/icon",
contentType: "image/png",
module: {
generateImageMetadata: async () => [{ id: "small" }],
default: () => new Response("icon"),
},
} satisfies MetadataFileRoute;
const unknownResponse = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/icon/large",
makeThenableParams,
});
const invalidResponse = await handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/icon/bad/id",
makeThenableParams,
});
expect(unknownResponse?.status).toBe(404);
expect(invalidResponse).toBeNull();
});
it("throws when dynamic image metadata routes return non-Response values", async () => {
const route = {
type: "opengraph-image",
isDynamic: true,
filePath: "/tmp/app/opengraph-image.tsx",
routePrefix: "",
routeSegments: [],
servedUrl: "/opengraph-image",
contentType: "image/png",
module: {
default: () => ({ broken: true }),
},
} satisfies MetadataFileRoute;
await expect(
handleMetadataRouteRequest({
metadataRoutes: [route],
cleanPathname: "/opengraph-image",
makeThenableParams,
}),
).rejects.toThrow(
"Dynamic metadata opengraph-image route /opengraph-image must return a Response.",
);
});
});