Skip to content

Commit b5999bc

Browse files
committed
fix: set Content-Type header when preview server serves HTML
The preview-server middleware in `configurePreviewServer` responded with `res.end(html)` without a `Content-Type` header, for both matched entry files and the SPA fallback. Browsers usually sniff it as HTML, but some environments and tooling won't. Set an explicit `Content-Type: text/html; charset=utf-8` before ending the response. Also collapse the two separate lookup loops (candidate files, then the `index.html`/`index.htm` SPA fallback) into a single deduplicated candidate list. Add unit tests covering the header, the SPA fallback, the no-match pass-through, and non-HTML requests. Closes #142 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018H1uPDiSpxrHtQvyAxUEVC
1 parent d0363ba commit b5999bc

2 files changed

Lines changed: 176 additions & 14 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import path from "node:path";
2+
import { describe, it, expect, vi, beforeEach } from "vitest";
3+
import { readFile } from "node:fs/promises";
4+
import { serverPlugin } from "./server";
5+
6+
vi.mock("node:fs/promises", () => ({
7+
readFile: vi.fn(),
8+
}));
9+
10+
const mockReadFile = vi.mocked(readFile);
11+
12+
const OUT_DIR = path.resolve("/project", "dist/public");
13+
14+
interface MockRes {
15+
headers: Record<string, string>;
16+
body: string | undefined;
17+
ended: boolean;
18+
setHeader(name: string, value: string): void;
19+
end(body?: string): void;
20+
}
21+
22+
function createRes(): MockRes {
23+
return {
24+
headers: {},
25+
body: undefined,
26+
ended: false,
27+
setHeader(name, value) {
28+
this.headers[name] = value;
29+
},
30+
end(body) {
31+
this.body = body;
32+
this.ended = true;
33+
},
34+
};
35+
}
36+
37+
type Middleware = (
38+
req: unknown,
39+
res: unknown,
40+
next: (err?: unknown) => void,
41+
) => void | Promise<void>;
42+
43+
/**
44+
* Instantiate the preview middleware with `resolvedOutDir` pointing at
45+
* {@link OUT_DIR}, pretending the given files (relative to that dir) exist.
46+
*/
47+
function createPreviewMiddleware(existingFiles: string[]): Middleware {
48+
mockReadFile.mockImplementation(((filePath: string) => {
49+
const rel = path.relative(OUT_DIR, filePath);
50+
if (existingFiles.includes(rel)) {
51+
return Promise.resolve(`<!DOCTYPE html><!-- ${rel} -->`);
52+
}
53+
return Promise.reject(
54+
Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
55+
);
56+
}) as never);
57+
58+
const plugin = serverPlugin();
59+
60+
// `resolvedOutDir` is populated by the `configResolved` hook.
61+
const configResolved = plugin.configResolved;
62+
const configResolvedHandler =
63+
typeof configResolved === "function"
64+
? configResolved
65+
: configResolved?.handler;
66+
configResolvedHandler?.call(
67+
{} as never,
68+
{
69+
root: "/project",
70+
environments: { client: { build: { outDir: "dist/public" } } },
71+
} as never,
72+
);
73+
74+
// The preview hook returns a post-hook that installs the middleware.
75+
let middleware: Middleware | undefined;
76+
const mockServer = {
77+
middlewares: {
78+
use: (fn: Middleware) => {
79+
middleware = fn;
80+
},
81+
},
82+
};
83+
const hook = plugin.configurePreviewServer;
84+
const handler = typeof hook === "function" ? hook : hook?.handler;
85+
const post = handler?.call({} as never, mockServer as never);
86+
if (typeof post === "function") {
87+
post();
88+
}
89+
if (!middleware) {
90+
throw new Error("preview middleware was not registered");
91+
}
92+
return middleware;
93+
}
94+
95+
describe("configurePreviewServer", () => {
96+
beforeEach(() => {
97+
mockReadFile.mockReset();
98+
});
99+
100+
it("serves a matched entry file with an explicit HTML Content-Type", async () => {
101+
const middleware = createPreviewMiddleware(["about.html"]);
102+
const res = createRes();
103+
const next = vi.fn();
104+
105+
await middleware(
106+
{ headers: { accept: "text/html", host: "localhost" }, url: "/about" },
107+
res,
108+
next,
109+
);
110+
111+
expect(res.headers["Content-Type"]).toBe("text/html; charset=utf-8");
112+
expect(res.body).toContain("about.html");
113+
expect(next).not.toHaveBeenCalled();
114+
});
115+
116+
it("sets the Content-Type on the SPA fallback for unmatched routes", async () => {
117+
const middleware = createPreviewMiddleware(["index.html"]);
118+
const res = createRes();
119+
const next = vi.fn();
120+
121+
await middleware(
122+
{
123+
headers: { accept: "text/html", host: "localhost" },
124+
url: "/deep/route",
125+
},
126+
res,
127+
next,
128+
);
129+
130+
expect(res.headers["Content-Type"]).toBe("text/html; charset=utf-8");
131+
expect(res.body).toContain("index.html");
132+
expect(next).not.toHaveBeenCalled();
133+
});
134+
135+
it("calls next() without a Content-Type when nothing matches", async () => {
136+
const middleware = createPreviewMiddleware([]);
137+
const res = createRes();
138+
const next = vi.fn();
139+
140+
await middleware(
141+
{ headers: { accept: "text/html", host: "localhost" }, url: "/missing" },
142+
res,
143+
next,
144+
);
145+
146+
expect(res.ended).toBe(false);
147+
expect(res.headers["Content-Type"]).toBeUndefined();
148+
expect(next).toHaveBeenCalledTimes(1);
149+
});
150+
151+
it("passes non-HTML requests through untouched", async () => {
152+
const middleware = createPreviewMiddleware(["index.html"]);
153+
const res = createRes();
154+
const next = vi.fn();
155+
156+
await middleware(
157+
{ headers: { accept: "application/json", host: "localhost" }, url: "/" },
158+
res,
159+
next,
160+
);
161+
162+
expect(res.ended).toBe(false);
163+
expect(next).toHaveBeenCalledTimes(1);
164+
expect(mockReadFile).not.toHaveBeenCalled();
165+
});
166+
});

packages/static/src/plugin/server.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,26 +54,22 @@ export const serverPlugin = (): Plugin => {
5454
if (req.headers.accept?.includes("text/html")) {
5555
const urlPath = new URL(req.url!, `http://${req.headers.host}`)
5656
.pathname;
57-
const candidates = urlPathToFileCandidates(urlPath);
57+
// Entry files matching the URL path, followed by the SPA
58+
// fallback (index.html / index.htm) for unmatched routes.
59+
const candidates = [
60+
...new Set([
61+
...urlPathToFileCandidates(urlPath),
62+
"index.html",
63+
"index.htm",
64+
]),
65+
];
5866
for (const candidate of candidates) {
5967
try {
6068
const html = await readFile(
6169
path.join(resolvedOutDir, candidate),
6270
"utf-8",
6371
);
64-
res.end(html);
65-
return;
66-
} catch {
67-
// Try next candidate
68-
}
69-
}
70-
// SPA fallback: try serving index.html or index.htm for unmatched routes
71-
for (const indexFile of ["index.html", "index.htm"]) {
72-
try {
73-
const html = await readFile(
74-
path.join(resolvedOutDir, indexFile),
75-
"utf-8",
76-
);
72+
res.setHeader("Content-Type", "text/html; charset=utf-8");
7773
res.end(html);
7874
return;
7975
} catch {

0 commit comments

Comments
 (0)