mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
1551f91cbd
* 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.
467 lines
15 KiB
TypeScript
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.",
|
|
);
|
|
});
|
|
});
|