Skip to content

Commit b779754

Browse files
Boyeepjames-elicx
andauthored
fix(server): serve static assets with standard MIME types (#2713)
* fix(server): serve static assets with standard MIME types * fix(server): add UTF-8 to textual static asset types * fix(server): match Next static charset casing * fix(server): lowercase MIME charset values --------- Co-authored-by: James <james@eli.cx>
1 parent 3570928 commit b779754

4 files changed

Lines changed: 132 additions & 31 deletions

File tree

packages/vinext/src/server/prod-server.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import fs from "node:fs";
2424
import fsp from "node:fs/promises";
2525
import path from "pathslash";
2626
import zlib from "node:zlib";
27-
import { StaticFileCache, CONTENT_TYPES, etagFromFilenameHash } from "./static-file-cache.js";
27+
import { StaticFileCache, contentTypeForPath, etagFromFilenameHash } from "./static-file-cache.js";
2828
import {
2929
isImageOptimizationPath,
3030
IMAGE_CONTENT_SECURITY_POLICY,
@@ -690,7 +690,7 @@ async function tryServeStatic(
690690
if (!resolved) return false;
691691

692692
const ext = path.extname(resolved.path);
693-
const ct = CONTENT_TYPES[ext] ?? "application/octet-stream";
693+
const ct = contentTypeForPath(resolved.path);
694694
// Mirror the StaticFileCache's `isHashed` rule: assets under Vite's
695695
// `assetsDir` carry a content hash. `pathname` always has a leading `/`,
696696
// so a single `includes` covers both the root-level `/<ASSET_PREFIX_URL_DIR>/...`
@@ -1446,8 +1446,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
14461446
}
14471447
// Block SVG and other unsafe content types by checking the file extension.
14481448
// SVG is only allowed when dangerouslyAllowSVG is enabled in next.config.js.
1449-
const ext = path.extname(params.imageUrl).toLowerCase();
1450-
const ct = CONTENT_TYPES[ext] ?? "application/octet-stream";
1449+
const ct = contentTypeForPath(params.imageUrl);
14511450
if (!isSafeImageContentType(ct, imageConfig?.dangerouslyAllowSVG)) {
14521451
res.writeHead(400);
14531452
res.end("The requested resource is not an allowed image type");
@@ -1799,8 +1798,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
17991798
}
18001799
// Block SVG and other unsafe content types.
18011800
// SVG is only allowed when dangerouslyAllowSVG is enabled.
1802-
const ext = path.extname(params.imageUrl).toLowerCase();
1803-
const ct = CONTENT_TYPES[ext] ?? "application/octet-stream";
1801+
const ct = contentTypeForPath(params.imageUrl);
18041802
if (!isSafeImageContentType(ct, pagesImageConfig?.dangerouslyAllowSVG)) {
18051803
res.writeHead(400);
18061804
res.end("The requested resource is not an allowed image type");

packages/vinext/src/server/static-file-cache.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,45 @@ import { ASSET_PREFIX_URL_DIR } from "../utils/asset-prefix.js";
1717

1818
/** Content-type lookup for static assets. Shared with prod-server.ts. */
1919
export const CONTENT_TYPES: Record<string, string> = {
20-
".js": "application/javascript",
21-
".mjs": "application/javascript",
22-
".css": "text/css",
20+
".avif": "image/avif",
21+
".bmp": "image/bmp",
22+
".csv": "text/csv; charset=utf-8",
23+
".eot": "application/vnd.ms-fontobject",
24+
".gif": "image/gif",
25+
".heic": "image/heic",
26+
".js": "application/javascript; charset=utf-8",
27+
".mjs": "application/javascript; charset=utf-8",
28+
".css": "text/css; charset=utf-8",
2329
".html": "text/html; charset=utf-8",
24-
".json": "application/json",
25-
".txt": "text/plain; charset=utf-8",
26-
".png": "image/png",
27-
".jpg": "image/jpeg",
30+
".ico": "image/x-icon",
2831
".jpeg": "image/jpeg",
29-
".gif": "image/gif",
32+
".jpg": "image/jpeg",
33+
".json": "application/json; charset=utf-8",
34+
".map": "application/json; charset=utf-8",
35+
".mp3": "audio/mpeg",
36+
".mp4": "video/mp4",
37+
".ogg": "audio/ogg",
38+
".ogv": "video/ogg",
39+
".pdf": "application/pdf",
40+
".png": "image/png",
41+
".rsc": "text/x-component",
3042
".svg": "image/svg+xml",
31-
".ico": "image/x-icon",
32-
".woff": "font/woff",
33-
".woff2": "font/woff2",
3443
".ttf": "font/ttf",
35-
".eot": "application/vnd.ms-fontobject",
44+
".txt": "text/plain; charset=utf-8",
45+
".wasm": "application/wasm",
46+
".webm": "video/webm",
47+
".webmanifest": "application/manifest+json",
3648
".webp": "image/webp",
37-
".avif": "image/avif",
38-
".map": "application/json",
39-
".rsc": "text/x-component",
49+
".woff": "font/woff",
50+
".woff2": "font/woff2",
51+
".xml": "application/xml",
4052
};
4153

54+
/** Resolve a path's MIME type with case-insensitive extension matching. */
55+
export function contentTypeForPath(filePath: string): string {
56+
return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
57+
}
58+
4259
/**
4360
* Files below this size are buffered in memory at startup for zero-syscall
4461
* serving via res.end(buffer). Above this, files stream via createReadStream.
@@ -118,7 +135,7 @@ export class StaticFileCache {
118135
if (relativePath.startsWith(".vite/") || relativePath === ".vite") continue;
119136

120137
const ext = path.extname(relativePath);
121-
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
138+
const contentType = contentTypeForPath(relativePath);
122139
// Files under Vite's `assetsDir` are content-hashed. The default
123140
// layout writes to `<ASSET_PREFIX_URL_DIR>/` (Next.js's canonical
124141
// convention); when `assetPrefix` is a path prefix the layout

tests/serve-static.test.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import fsp from "node:fs/promises";
77
import path from "node:path";
88
import os from "node:os";
99
import zlib from "node:zlib";
10+
import http from "node:http";
1011
import { StaticFileCache } from "../packages/vinext/src/server/static-file-cache.js";
1112
import { tryServeStatic } from "../packages/vinext/src/server/prod-server.js";
1213
import type { IncomingMessage, ServerResponse } from "node:http";
@@ -131,7 +132,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
131132
expect(served).toBe(true);
132133
expect(captured.headers["Content-Encoding"]).toBe("br");
133134
expect(captured.headers["Content-Length"]).toBe(String(brContent.length));
134-
expect(captured.headers["Content-Type"]).toBe("application/javascript");
135+
expect(captured.headers["Content-Type"]).toBe("application/javascript; charset=utf-8");
135136
// Body should be the precompressed brotli content
136137
const decompressed = zlib.brotliDecompressSync(captured.body).toString();
137138
expect(decompressed).toBe(jsContent);
@@ -480,7 +481,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
480481
await captured.ended;
481482
expect(served).toBe(true);
482483
expect(captured.status).toBe(200);
483-
expect(captured.headers["Content-Type"]).toBe("application/javascript");
484+
expect(captured.headers["Content-Type"]).toBe("application/javascript; charset=utf-8");
484485
expect(captured.headers["Content-Length"]).toBe(String(jsContent.length));
485486
expect(captured.body.length).toBe(0); // no body for HEAD
486487
});
@@ -729,7 +730,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
729730
await captured.ended;
730731
expect(served).toBe(true);
731732
expect(captured.status).toBe(200);
732-
expect(captured.headers["Content-Type"]).toBe("application/javascript");
733+
expect(captured.headers["Content-Type"]).toBe("application/javascript; charset=utf-8");
733734
expect(captured.body.toString()).toBe("slow path content");
734735
});
735736

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

799+
it("serves Next-compatible MIME types over a real HTTP response", async () => {
800+
// Next.js uses its bundled `send` MIME database, which adds UTF-8 to
801+
// text/*, application/javascript, and application/json responses.
802+
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/serve-static.ts
803+
await Promise.all([
804+
writeFile(clientDir, "script.js", "console.log('ok')"),
805+
writeFile(clientDir, "style.css", "body {}"),
806+
writeFile(clientDir, "data.json", "{}"),
807+
writeFile(clientDir, "script.js.map", "{}"),
808+
writeFile(clientDir, "table.csv", "name,value"),
809+
writeFile(clientDir, "module.wasm", Buffer.from([0, 97, 115, 109])),
810+
]);
811+
812+
for (const cache of [await StaticFileCache.create(clientDir), undefined]) {
813+
const server = http.createServer((req, res) => {
814+
void tryServeStatic(req, res, clientDir, req.url ?? "/", false, cache)
815+
.then((served) => {
816+
if (!served) {
817+
res.statusCode = 404;
818+
res.end();
819+
}
820+
})
821+
.catch((error: Error) => res.destroy(error));
822+
});
823+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
824+
825+
try {
826+
const address = server.address();
827+
if (!address || typeof address === "string") throw new Error("HTTP server did not bind");
828+
const origin = `http://127.0.0.1:${address.port}`;
829+
830+
for (const [pathname, expected] of [
831+
["/script.js", "application/javascript; charset=utf-8"],
832+
["/style.css", "text/css; charset=utf-8"],
833+
["/data.json", "application/json; charset=utf-8"],
834+
["/script.js.map", "application/json; charset=utf-8"],
835+
["/table.csv", "text/csv; charset=utf-8"],
836+
["/module.wasm", "application/wasm"],
837+
] as const) {
838+
const response = await fetch(origin + pathname);
839+
expect(response.status).toBe(200);
840+
expect(response.headers.get("content-type")).toBe(expected);
841+
}
842+
843+
const headResponse = await fetch(origin + "/script.js", { method: "HEAD" });
844+
expect(headResponse.headers.get("content-type")).toBe(
845+
"application/javascript; charset=utf-8",
846+
);
847+
expect(await headResponse.text()).toBe("");
848+
} finally {
849+
await new Promise<void>((resolve, reject) => {
850+
server.close((error) => (error ? reject(error) : resolve()));
851+
});
852+
}
853+
}
854+
});
855+
798856
// ── URL-encoded characters in path ─────────────────────────────
799857
//
800858
// Regression test for https://github.com/cloudflare/vinext/issues/1472
@@ -829,7 +887,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
829887
await captured.ended;
830888
expect(served).toBe(true);
831889
expect(captured.status).toBe(200);
832-
expect(captured.headers["Content-Type"]).toBe("text/css");
890+
expect(captured.headers["Content-Type"]).toBe("text/css; charset=utf-8");
833891
expect(captured.body.toString()).toBe(cssContent);
834892
});
835893

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

@@ -880,7 +938,7 @@ describe("tryServeStatic (with StaticFileCache)", () => {
880938
await captured.ended;
881939
expect(served).toBe(true);
882940
expect(captured.status).toBe(200);
883-
expect(captured.headers["Content-Type"]).toBe("text/css");
941+
expect(captured.headers["Content-Type"]).toBe("text/css; charset=utf-8");
884942
expect(captured.body.toString()).toBe(cssContent);
885943
});
886944

tests/static-file-cache.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ describe("StaticFileCache", () => {
7474
const entry = cache.lookup("/_next/static/index-abc123.js");
7575

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

324324
expect(cache.lookup("/_next/static/style-aaa.css")!.original.headers["Content-Type"]).toBe(
325-
"text/css",
325+
"text/css; charset=utf-8",
326326
);
327327
expect(cache.lookup("/_next/static/data-bbb.json")!.original.headers["Content-Type"]).toBe(
328-
"application/json",
328+
"application/json; charset=utf-8",
329329
);
330330
expect(cache.lookup("/logo.svg")!.original.headers["Content-Type"]).toBe("image/svg+xml");
331331
expect(cache.lookup("/photo.webp")!.original.headers["Content-Type"]).toBe("image/webp");
@@ -341,6 +341,34 @@ describe("StaticFileCache", () => {
341341
);
342342
});
343343

344+
it("serves common web assets with their standard content types", async () => {
345+
await Promise.all([
346+
writeFile(clientDir, "module.wasm", "wasm"),
347+
writeFile(clientDir, "movie.mp4", "video"),
348+
writeFile(clientDir, "document.pdf", "pdf"),
349+
writeFile(clientDir, "site.webmanifest", "{}"),
350+
writeFile(clientDir, "feed.xml", "<feed />"),
351+
]);
352+
353+
const cache = await StaticFileCache.create(clientDir);
354+
355+
expect(cache.lookup("/module.wasm")!.original.headers["Content-Type"]).toBe("application/wasm");
356+
expect(cache.lookup("/movie.mp4")!.original.headers["Content-Type"]).toBe("video/mp4");
357+
expect(cache.lookup("/document.pdf")!.original.headers["Content-Type"]).toBe("application/pdf");
358+
expect(cache.lookup("/site.webmanifest")!.original.headers["Content-Type"]).toBe(
359+
"application/manifest+json",
360+
);
361+
expect(cache.lookup("/feed.xml")!.original.headers["Content-Type"]).toBe("application/xml");
362+
});
363+
364+
it("matches file extensions case-insensitively", async () => {
365+
await writeFile(clientDir, "logo.SVG", "<svg />");
366+
367+
const cache = await StaticFileCache.create(clientDir);
368+
369+
expect(cache.lookup("/logo.SVG")!.original.headers["Content-Type"]).toBe("image/svg+xml");
370+
});
371+
344372
// ── Nested directory scanning ──────────────────────────────────
345373

346374
it("scans nested directories recursively", async () => {

0 commit comments

Comments
 (0)