Skip to content
Open
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
10 changes: 4 additions & 6 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `/<ASSET_PREFIX_URL_DIR>/...`
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
41 changes: 29 additions & 12 deletions packages/vinext/src/server/static-file-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
".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.
Expand Down Expand Up @@ -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 `<ASSET_PREFIX_URL_DIR>/` (Next.js's canonical
// convention); when `assetPrefix` is a path prefix the layout
Expand Down
28 changes: 28 additions & 0 deletions tests/static-file-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<feed />"),
]);

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", "<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 () => {
Expand Down
Loading