Skip to content
Merged
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
49 changes: 33 additions & 16 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> = {
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".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; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".json": "application/json",
".txt": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".gif": "image/gif",
".jpg": "image/jpeg",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "audio/ogg",
".ogv": "video/ogg",
".pdf": "application/pdf",
".png": "image/png",
".rsc": "text/x-component",
".svg": "image/svg+xml",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity nit (non-blocking): mime-db marks both image/svg+xml and application/xml with charset: UTF-8, so strict send/mime-types parity would emit image/svg+xml; charset=utf-8 and application/xml; charset=utf-8. As written they're bare, which is inconsistent with .csv/.txt/.html in the same map. Either add the charset or note the deliberate divergence. (isSafeImageContentType splits on ;, so adding it wouldn't affect the SVG security gate.)

".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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Next.js-parity nit (non-blocking): Next.js serve-static delegates to sendmime-types, which appends charset whenever mime.charset(type) returns a value. mime-db marks both image/svg+xml and application/xml with charset: UTF-8, so strict parity would serve image/svg+xml; charset=utf-8 and application/xml; charset=utf-8. This PR emits them bare, which is inconsistent with .csv/.txt/.html (which do get the charset). Serving them without charset is harmless in practice, but since the PR's stated goal is matching the send/mime database it's worth either adding the charset to .svg/.xml or noting the deliberate divergence.

};

/** 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
70 changes: 64 additions & 6 deletions tests/serve-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import fsp from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import zlib from "node:zlib";
import http from "node:http";
import { StaticFileCache } from "../packages/vinext/src/server/static-file-cache.js";
import { tryServeStatic } from "../packages/vinext/src/server/prod-server.js";
import type { IncomingMessage, ServerResponse } from "node:http";
Expand Down Expand Up @@ -131,7 +132,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
expect(served).toBe(true);
expect(captured.headers["Content-Encoding"]).toBe("br");
expect(captured.headers["Content-Length"]).toBe(String(brContent.length));
expect(captured.headers["Content-Type"]).toBe("application/javascript");
expect(captured.headers["Content-Type"]).toBe("application/javascript; charset=utf-8");
// Body should be the precompressed brotli content
const decompressed = zlib.brotliDecompressSync(captured.body).toString();
expect(decompressed).toBe(jsContent);
Expand Down Expand Up @@ -480,7 +481,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.headers["Content-Type"]).toBe("application/javascript");
expect(captured.headers["Content-Type"]).toBe("application/javascript; charset=utf-8");
expect(captured.headers["Content-Length"]).toBe(String(jsContent.length));
expect(captured.body.length).toBe(0); // no body for HEAD
});
Expand Down Expand Up @@ -729,7 +730,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.headers["Content-Type"]).toBe("application/javascript");
expect(captured.headers["Content-Type"]).toBe("application/javascript; charset=utf-8");
expect(captured.body.toString()).toBe("slow path content");
});

Expand Down Expand Up @@ -795,6 +796,63 @@ describe("tryServeStatic (with StaticFileCache)", () => {
expect(captured.body.length).toBe(0);
});

it("serves Next-compatible MIME types over a real HTTP response", async () => {
// Next.js uses its bundled `send` MIME database, which adds UTF-8 to
// text/*, application/javascript, and application/json responses.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/serve-static.ts
await Promise.all([
writeFile(clientDir, "script.js", "console.log('ok')"),
writeFile(clientDir, "style.css", "body {}"),
writeFile(clientDir, "data.json", "{}"),
writeFile(clientDir, "script.js.map", "{}"),
writeFile(clientDir, "table.csv", "name,value"),
writeFile(clientDir, "module.wasm", Buffer.from([0, 97, 115, 109])),
]);

for (const cache of [await StaticFileCache.create(clientDir), undefined]) {
const server = http.createServer((req, res) => {
void tryServeStatic(req, res, clientDir, req.url ?? "/", false, cache)
.then((served) => {
if (!served) {
res.statusCode = 404;
res.end();
}
})
.catch((error: Error) => res.destroy(error));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));

try {
const address = server.address();
if (!address || typeof address === "string") throw new Error("HTTP server did not bind");
const origin = `http://127.0.0.1:${address.port}`;

for (const [pathname, expected] of [
["/script.js", "application/javascript; charset=utf-8"],
["/style.css", "text/css; charset=utf-8"],
["/data.json", "application/json; charset=utf-8"],
["/script.js.map", "application/json; charset=utf-8"],
["/table.csv", "text/csv; charset=utf-8"],
["/module.wasm", "application/wasm"],
] as const) {
const response = await fetch(origin + pathname);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(expected);
}

const headResponse = await fetch(origin + "/script.js", { method: "HEAD" });
expect(headResponse.headers.get("content-type")).toBe(
"application/javascript; charset=utf-8",
);
expect(await headResponse.text()).toBe("");
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}
});

// ── URL-encoded characters in path ─────────────────────────────
//
// Regression test for https://github.com/cloudflare/vinext/issues/1472
Expand Down Expand Up @@ -829,7 +887,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.headers["Content-Type"]).toBe("text/css");
expect(captured.headers["Content-Type"]).toBe("text/css; charset=utf-8");
expect(captured.body.toString()).toBe(cssContent);
});

Expand All @@ -851,7 +909,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.headers["Content-Type"]).toBe("text/css");
expect(captured.headers["Content-Type"]).toBe("text/css; charset=utf-8");
expect(captured.body.toString()).toBe(cssContent);
});

Expand Down Expand Up @@ -880,7 +938,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
await captured.ended;
expect(served).toBe(true);
expect(captured.status).toBe(200);
expect(captured.headers["Content-Type"]).toBe("text/css");
expect(captured.headers["Content-Type"]).toBe("text/css; charset=utf-8");
expect(captured.body.toString()).toBe(cssContent);
});

Expand Down
34 changes: 31 additions & 3 deletions tests/static-file-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe("StaticFileCache", () => {
const entry = cache.lookup("/_next/static/index-abc123.js");

expect(entry).toBeDefined();
expect(entry!.original.headers["Content-Type"]).toBe("application/javascript");
expect(entry!.original.headers["Content-Type"]).toBe("application/javascript; charset=utf-8");
expect(entry!.original.headers["Content-Length"]).toBe("12"); // "const x = 1;"
expect(entry!.original.path).toBe(
toSlash(path.join(clientDir, "_next/static/index-abc123.js")),
Expand Down Expand Up @@ -322,10 +322,10 @@ describe("StaticFileCache", () => {
const cache = await StaticFileCache.create(clientDir);

expect(cache.lookup("/_next/static/style-aaa.css")!.original.headers["Content-Type"]).toBe(
"text/css",
"text/css; charset=utf-8",
);
expect(cache.lookup("/_next/static/data-bbb.json")!.original.headers["Content-Type"]).toBe(
"application/json",
"application/json; charset=utf-8",
);
expect(cache.lookup("/logo.svg")!.original.headers["Content-Type"]).toBe("image/svg+xml");
expect(cache.lookup("/photo.webp")!.original.headers["Content-Type"]).toBe("image/webp");
Expand All @@ -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