diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 44ceda606..35a2bb222 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -24,7 +24,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "pathslash"; import zlib from "node:zlib"; -import { StaticFileCache, CONTENT_TYPES, etagFromFilenameHash } from "./static-file-cache.js"; +import { StaticFileCache, contentTypeForPath, etagFromFilenameHash } from "./static-file-cache.js"; import { isImageOptimizationPath, IMAGE_CONTENT_SECURITY_POLICY, @@ -690,7 +690,7 @@ async function tryServeStatic( if (!resolved) return false; const ext = path.extname(resolved.path); - const ct = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const ct = contentTypeForPath(resolved.path); // Mirror the StaticFileCache's `isHashed` rule: assets under Vite's // `assetsDir` carry a content hash. `pathname` always has a leading `/`, // so a single `includes` covers both the root-level `//...` @@ -1446,8 +1446,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { } // Block SVG and other unsafe content types by checking the file extension. // SVG is only allowed when dangerouslyAllowSVG is enabled in next.config.js. - const ext = path.extname(params.imageUrl).toLowerCase(); - const ct = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const ct = contentTypeForPath(params.imageUrl); if (!isSafeImageContentType(ct, imageConfig?.dangerouslyAllowSVG)) { res.writeHead(400); res.end("The requested resource is not an allowed image type"); @@ -1799,8 +1798,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { } // Block SVG and other unsafe content types. // SVG is only allowed when dangerouslyAllowSVG is enabled. - const ext = path.extname(params.imageUrl).toLowerCase(); - const ct = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const ct = contentTypeForPath(params.imageUrl); if (!isSafeImageContentType(ct, pagesImageConfig?.dangerouslyAllowSVG)) { res.writeHead(400); res.end("The requested resource is not an allowed image type"); diff --git a/packages/vinext/src/server/static-file-cache.ts b/packages/vinext/src/server/static-file-cache.ts index c769e8672..99e1c2214 100644 --- a/packages/vinext/src/server/static-file-cache.ts +++ b/packages/vinext/src/server/static-file-cache.ts @@ -17,28 +17,45 @@ import { ASSET_PREFIX_URL_DIR } from "../utils/asset-prefix.js"; /** Content-type lookup for static assets. Shared with prod-server.ts. */ export const CONTENT_TYPES: Record = { + ".avif": "image/avif", + ".bmp": "image/bmp", + ".csv": "text/csv; charset=utf-8", + ".eot": "application/vnd.ms-fontobject", + ".gif": "image/gif", + ".heic": "image/heic", ".js": "application/javascript", ".mjs": "application/javascript", ".css": "text/css", ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", ".json": "application/json", - ".txt": "text/plain; charset=utf-8", + ".map": "application/json", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".ogg": "audio/ogg", + ".ogv": "video/ogg", + ".pdf": "application/pdf", ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", + ".rsc": "text/x-component", ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", ".ttf": "font/ttf", - ".eot": "application/vnd.ms-fontobject", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", + ".webm": "video/webm", + ".webmanifest": "application/manifest+json", ".webp": "image/webp", - ".avif": "image/avif", - ".map": "application/json", - ".rsc": "text/x-component", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".xml": "application/xml", }; +/** Resolve a path's MIME type with case-insensitive extension matching. */ +export function contentTypeForPath(filePath: string): string { + return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + /** * Files below this size are buffered in memory at startup for zero-syscall * serving via res.end(buffer). Above this, files stream via createReadStream. @@ -118,7 +135,7 @@ export class StaticFileCache { if (relativePath.startsWith(".vite/") || relativePath === ".vite") continue; const ext = path.extname(relativePath); - const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const contentType = contentTypeForPath(relativePath); // Files under Vite's `assetsDir` are content-hashed. The default // layout writes to `/` (Next.js's canonical // convention); when `assetPrefix` is a path prefix the layout diff --git a/tests/static-file-cache.test.ts b/tests/static-file-cache.test.ts index e3587b9cf..0f1fba61d 100644 --- a/tests/static-file-cache.test.ts +++ b/tests/static-file-cache.test.ts @@ -341,6 +341,34 @@ describe("StaticFileCache", () => { ); }); + it("serves common web assets with their standard content types", async () => { + await Promise.all([ + writeFile(clientDir, "module.wasm", "wasm"), + writeFile(clientDir, "movie.mp4", "video"), + writeFile(clientDir, "document.pdf", "pdf"), + writeFile(clientDir, "site.webmanifest", "{}"), + writeFile(clientDir, "feed.xml", ""), + ]); + + const cache = await StaticFileCache.create(clientDir); + + expect(cache.lookup("/module.wasm")!.original.headers["Content-Type"]).toBe("application/wasm"); + expect(cache.lookup("/movie.mp4")!.original.headers["Content-Type"]).toBe("video/mp4"); + expect(cache.lookup("/document.pdf")!.original.headers["Content-Type"]).toBe("application/pdf"); + expect(cache.lookup("/site.webmanifest")!.original.headers["Content-Type"]).toBe( + "application/manifest+json", + ); + expect(cache.lookup("/feed.xml")!.original.headers["Content-Type"]).toBe("application/xml"); + }); + + it("matches file extensions case-insensitively", async () => { + await writeFile(clientDir, "logo.SVG", ""); + + const cache = await StaticFileCache.create(clientDir); + + expect(cache.lookup("/logo.SVG")!.original.headers["Content-Type"]).toBe("image/svg+xml"); + }); + // ── Nested directory scanning ────────────────────────────────── it("scans nested directories recursively", async () => {