mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
51a63131b5
Adds focused tests verifying that the static-asset layer serves CSS files when the browser percent-encodes special characters in the path: * `tests/serve-static.test.ts` — three cases against `tryServeStatic`: the cache fast path with an encoded URL, the slow path with an encoded URL, and the literal-character pathname that the request pipeline produces after `normalizePathnameForRouteMatchStrict` runs. * `tests/pages-css-url-encoding.test.ts` — full build-and-serve test for the Pages Router that imports `my@style.css` and `with space.css` from `pages/index.tsx`, then asserts every emitted stylesheet link resolves to a 200 after `encodeURI` is applied (matching browser behaviour). All three tests pass against the current implementation: `tryServeStatic` decodes percent-encoded pathnames before cache lookup, and the cache indexes files by their literal on-disk names. These tests document the contract and guard against future regressions. Refs cloudflare/vinext#1472
831 lines
28 KiB
TypeScript
831 lines
28 KiB
TypeScript
/**
|
|
* Tests for the refactored tryServeStatic that uses StaticFileCache
|
|
* and serves precompressed assets without runtime compression overhead.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test";
|
|
import fsp from "node:fs/promises";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import zlib from "node:zlib";
|
|
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";
|
|
|
|
async function writeFile(
|
|
clientDir: string,
|
|
relativePath: string,
|
|
content: string | Buffer,
|
|
): Promise<void> {
|
|
const fullPath = path.join(clientDir, relativePath);
|
|
await fsp.mkdir(path.dirname(fullPath), { recursive: true });
|
|
await fsp.writeFile(fullPath, content);
|
|
}
|
|
|
|
/**
|
|
* Create a mock request with optional headers and method.
|
|
*/
|
|
function mockReq(
|
|
acceptEncoding?: string,
|
|
extraHeaders?: Record<string, string>,
|
|
method: string = "GET",
|
|
): IncomingMessage {
|
|
const headers: Record<string, string> = { ...extraHeaders };
|
|
if (acceptEncoding) headers["accept-encoding"] = acceptEncoding;
|
|
return { headers, method } as unknown as IncomingMessage;
|
|
}
|
|
|
|
type CapturedResponse = {
|
|
status: number;
|
|
headers: Record<string, string | string[]>;
|
|
body: Buffer;
|
|
ended: Promise<void>;
|
|
};
|
|
|
|
/**
|
|
* Create a mock response that captures writeHead + streamed/ended body data.
|
|
*/
|
|
function mockRes(): { res: ServerResponse; captured: CapturedResponse } {
|
|
const chunks: Buffer[] = [];
|
|
let resolveEnded: () => void;
|
|
const ended = new Promise<void>((r) => {
|
|
resolveEnded = r;
|
|
});
|
|
|
|
const captured: CapturedResponse = {
|
|
status: 0,
|
|
headers: {},
|
|
body: Buffer.alloc(0),
|
|
ended,
|
|
};
|
|
|
|
const res = {
|
|
writeHead(status: number, headers: Record<string, string | string[]>) {
|
|
captured.status = status;
|
|
captured.headers = headers;
|
|
},
|
|
write(chunk: Buffer | string) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
return true;
|
|
},
|
|
end(chunk?: Buffer | string) {
|
|
if (chunk) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
captured.body = Buffer.concat(chunks);
|
|
resolveEnded!();
|
|
},
|
|
on(_event: string, _handler: (...args: unknown[]) => void) {
|
|
return res;
|
|
},
|
|
once(_event: string, _handler: (...args: unknown[]) => void) {
|
|
return res;
|
|
},
|
|
emit() {
|
|
return false;
|
|
},
|
|
removeListener() {
|
|
return res;
|
|
},
|
|
} as unknown as ServerResponse;
|
|
|
|
return { res, captured };
|
|
}
|
|
|
|
describe("tryServeStatic (with StaticFileCache)", () => {
|
|
let clientDir: string;
|
|
|
|
beforeEach(async () => {
|
|
clientDir = path.join(
|
|
os.tmpdir(),
|
|
`vinext-serve-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
await fsp.mkdir(clientDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(clientDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── Precompressed serving ──────────────────────────────────────
|
|
|
|
it("serves precompressed brotli for hashed assets when client accepts br", async () => {
|
|
const jsContent = "const app = () => {};\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/app-abc123.js", jsContent);
|
|
const brContent = zlib.brotliCompressSync(Buffer.from(jsContent));
|
|
await writeFile(clientDir, "_next/static/app-abc123.js.br", brContent);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("gzip, deflate, br");
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/app-abc123.js",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
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");
|
|
// Body should be the precompressed brotli content
|
|
const decompressed = zlib.brotliDecompressSync(captured.body).toString();
|
|
expect(decompressed).toBe(jsContent);
|
|
});
|
|
|
|
it("serves precompressed gzip when client accepts gzip but not br", async () => {
|
|
const cssContent = ".app { display: flex; }\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/styles-def456.css", cssContent);
|
|
const gzContent = zlib.gzipSync(Buffer.from(cssContent));
|
|
await writeFile(clientDir, "_next/static/styles-def456.css.gz", gzContent);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("gzip, deflate");
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/styles-def456.css",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.headers["Content-Encoding"]).toBe("gzip");
|
|
expect(captured.headers["Content-Length"]).toBe(String(gzContent.length));
|
|
});
|
|
|
|
it("serves original file with Content-Length when no encoding accepted", async () => {
|
|
const jsContent = "const x = 1;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/plain-ghi789.js", jsContent);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq(); // no Accept-Encoding
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/plain-ghi789.js",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.headers["Content-Encoding"]).toBeUndefined();
|
|
expect(captured.headers["Content-Length"]).toBe(String(jsContent.length));
|
|
});
|
|
|
|
it("preserves a non-200 status override for cached static files", async () => {
|
|
const jsContent = "export const blocked = true;\n";
|
|
await writeFile(clientDir, "_next/static/status-override-abc123.js", jsContent);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq(undefined, { "if-none-match": 'W/"abc123"' });
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/status-override-abc123.js",
|
|
true,
|
|
cache,
|
|
{ "x-middleware": "blocked" },
|
|
403,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(403);
|
|
expect(captured.headers["x-middleware"]).toBe("blocked");
|
|
expect(captured.headers["ETag"]).toBe('W/"abc123"');
|
|
expect(captured.body.toString()).toBe(jsContent);
|
|
});
|
|
|
|
// ── Cache miss / non-existent ──────────────────────────────────
|
|
|
|
it("returns false for non-existent files", async () => {
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("br");
|
|
const { res } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/nope-xxx999.js",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
expect(served).toBe(false);
|
|
});
|
|
|
|
// ── immutable cache-control on hashed assets ───────────────────
|
|
|
|
it("sets immutable cache-control for /assets/ files", async () => {
|
|
await writeFile(clientDir, "_next/static/chunk-aaa111.js", "code".repeat(100));
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq();
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/chunk-aaa111.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable");
|
|
});
|
|
|
|
// ── Extra headers merging ──────────────────────────────────────
|
|
|
|
it("merges extra headers into the response", async () => {
|
|
await writeFile(clientDir, "photo.jpg", Buffer.alloc(100));
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq();
|
|
const { res, captured } = mockRes();
|
|
|
|
const extraHeaders = { "X-Custom": "value", "Content-Security-Policy": "default-src 'self'" };
|
|
await tryServeStatic(req, res, clientDir, "/photo.jpg", false, cache, extraHeaders);
|
|
|
|
await captured.ended;
|
|
expect(captured.headers["X-Custom"]).toBe("value");
|
|
expect(captured.headers["Content-Security-Policy"]).toBe("default-src 'self'");
|
|
});
|
|
|
|
// ── Vary header ────────────────────────────────────────────────
|
|
|
|
it("sets Vary: Accept-Encoding when serving precompressed content", async () => {
|
|
const jsContent = "code\n".repeat(500);
|
|
await writeFile(clientDir, "_next/static/vary-bbb222.js", jsContent);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/vary-bbb222.js.br",
|
|
zlib.brotliCompressSync(Buffer.from(jsContent)),
|
|
);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("br");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/vary-bbb222.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.headers["Vary"]).toBe("Accept-Encoding");
|
|
});
|
|
|
|
// ── Directory traversal protection ─────────────────────────────
|
|
|
|
it("blocks directory traversal attempts", async () => {
|
|
await writeFile(clientDir, "_next/static/safe-ccc333.js", "safe");
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq();
|
|
const { res } = mockRes();
|
|
|
|
const served = await tryServeStatic(req, res, clientDir, "/../../../etc/passwd", true, cache);
|
|
|
|
expect(served).toBe(false);
|
|
});
|
|
|
|
it("blocks .vite/ internal directory access", async () => {
|
|
await writeFile(clientDir, ".vite/manifest.json", "{}");
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq();
|
|
const { res } = mockRes();
|
|
|
|
const served = await tryServeStatic(req, res, clientDir, "/.vite/manifest.json", true, cache);
|
|
|
|
expect(served).toBe(false);
|
|
});
|
|
|
|
// ── Async operation (no event loop blocking) ───────────────────
|
|
|
|
it("returns a Promise (async function)", async () => {
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq();
|
|
const { res } = mockRes();
|
|
|
|
const result = tryServeStatic(req, res, clientDir, "/nope", true, cache);
|
|
|
|
// Must return a Promise, not a boolean
|
|
expect(result).toBeInstanceOf(Promise);
|
|
});
|
|
|
|
// ── 304 Not Modified (conditional requests) ────────────────────
|
|
|
|
it("returns 304 when If-None-Match matches the ETag", async () => {
|
|
await writeFile(clientDir, "_next/static/cached-aaa111.js", "cached content");
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const entry = cache.lookup("/_next/static/cached-aaa111.js");
|
|
const etag = entry!.etag;
|
|
|
|
const req = mockReq(undefined, { "if-none-match": etag });
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/cached-aaa111.js",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.body.length).toBe(0); // no body on 304
|
|
});
|
|
|
|
it("returns 304 when If-None-Match contains the ETag in a comma-separated list", async () => {
|
|
await writeFile(clientDir, "_next/static/cached-list-aaa111.js", "cached content");
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const entry = cache.lookup("/_next/static/cached-list-aaa111.js");
|
|
const etag = entry!.etag;
|
|
|
|
const req = mockReq(undefined, {
|
|
"if-none-match": `W/"other", ${etag}, W/"another"`,
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/cached-list-aaa111.js",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.body.length).toBe(0);
|
|
});
|
|
|
|
it("returns 200 when If-None-Match does not match", async () => {
|
|
await writeFile(clientDir, "_next/static/stale-bbb222.js", "new content");
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq(undefined, { "if-none-match": 'W/"999-0"' });
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/stale-bbb222.js",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.body.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("returns 200 when no If-None-Match header is present", async () => {
|
|
await writeFile(clientDir, "_next/static/fresh-ccc333.js", "fresh content");
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq();
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/fresh-ccc333.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(200);
|
|
});
|
|
|
|
it("304 response excludes Content-Type per RFC 9110", async () => {
|
|
await writeFile(clientDir, "_next/static/rfc-aaa111.js", "rfc content");
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const entry = cache.lookup("/_next/static/rfc-aaa111.js");
|
|
|
|
const req = mockReq(undefined, { "if-none-match": entry!.etag });
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/rfc-aaa111.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers["Content-Type"]).toBeUndefined();
|
|
});
|
|
|
|
it("304 response includes ETag and Cache-Control but no Content-Length", async () => {
|
|
await writeFile(clientDir, "_next/static/headers-ddd444.js", "header test content");
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const entry = cache.lookup("/_next/static/headers-ddd444.js");
|
|
|
|
const req = mockReq(undefined, { "if-none-match": entry!.etag });
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/headers-ddd444.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers["ETag"]).toBe(entry!.etag);
|
|
expect(captured.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable");
|
|
expect(captured.headers["Content-Length"]).toBeUndefined();
|
|
});
|
|
|
|
// ── HEAD request optimization ──────────────────────────────────
|
|
|
|
it("HEAD request returns headers without streaming body", async () => {
|
|
const jsContent = "const x = 1;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/head-eee555.js", jsContent);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq(undefined, undefined, "HEAD");
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/head-eee555.js",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers["Content-Type"]).toBe("application/javascript");
|
|
expect(captured.headers["Content-Length"]).toBe(String(jsContent.length));
|
|
expect(captured.body.length).toBe(0); // no body for HEAD
|
|
});
|
|
|
|
it("HEAD request includes compressed Content-Length when precompressed variant exists", async () => {
|
|
const jsContent = "const app = () => {};\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/head-br-fff666.js", jsContent);
|
|
const brContent = zlib.brotliCompressSync(Buffer.from(jsContent));
|
|
await writeFile(clientDir, "_next/static/head-br-fff666.js.br", brContent);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("br", undefined, "HEAD");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/head-br-fff666.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers["Content-Encoding"]).toBe("br");
|
|
expect(captured.headers["Content-Length"]).toBe(String(brContent.length));
|
|
expect(captured.body.length).toBe(0);
|
|
});
|
|
|
|
// ── Zstandard precompressed serving ────────────────────────────
|
|
|
|
it("serves precompressed zstd when client accepts zstd", async () => {
|
|
const jsContent = "const zstd = true;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/zstd-ggg777.js", jsContent);
|
|
const zstdContent = zlib.zstdCompressSync(Buffer.from(jsContent));
|
|
await writeFile(clientDir, "_next/static/zstd-ggg777.js.zst", zstdContent);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("zstd, br, gzip");
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/zstd-ggg777.js",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.headers["Content-Encoding"]).toBe("zstd");
|
|
expect(captured.headers["Content-Length"]).toBe(String(zstdContent.length));
|
|
// Verify content decompresses correctly
|
|
const decompressed = zlib.zstdDecompressSync(captured.body).toString();
|
|
expect(decompressed).toBe(jsContent);
|
|
});
|
|
|
|
it("prefers zstd over br when both accepted and available", async () => {
|
|
const jsContent = "const priority = true;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/priority-hhh888.js", jsContent);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/priority-hhh888.js.zst",
|
|
zlib.zstdCompressSync(Buffer.from(jsContent)),
|
|
);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/priority-hhh888.js.br",
|
|
zlib.brotliCompressSync(Buffer.from(jsContent)),
|
|
);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("zstd, br, gzip");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/priority-hhh888.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.headers["Content-Encoding"]).toBe("zstd");
|
|
});
|
|
|
|
it("falls back to br when zstd accepted but no .zst file exists", async () => {
|
|
const jsContent = "const fallback = true;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/fallback-iii999.js", jsContent);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/fallback-iii999.js.br",
|
|
zlib.brotliCompressSync(Buffer.from(jsContent)),
|
|
);
|
|
// No .zst file
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("zstd, br, gzip");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/fallback-iii999.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.headers["Content-Encoding"]).toBe("br");
|
|
});
|
|
|
|
// ── Slow path (no cache) ───────────────────────────────────────
|
|
|
|
it("slow path serves static file without cache", async () => {
|
|
await writeFile(clientDir, "_next/static/nocache-aaa111.js", "slow path content");
|
|
|
|
const req = mockReq();
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/nocache-aaa111.js",
|
|
false,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers["Content-Type"]).toBe("application/javascript");
|
|
expect(captured.body.toString()).toBe("slow path content");
|
|
});
|
|
|
|
it("slow path returns false for non-existent files", async () => {
|
|
const req = mockReq();
|
|
const { res } = mockRes();
|
|
|
|
const served = await tryServeStatic(req, res, clientDir, "/nope.js", false);
|
|
|
|
expect(served).toBe(false);
|
|
});
|
|
|
|
it("slow path serves HEAD without body", async () => {
|
|
await writeFile(clientDir, "_next/static/head-slow-bbb222.js", "head content");
|
|
|
|
const req = mockReq(undefined, undefined, "HEAD");
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/head-slow-bbb222.js",
|
|
false,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers["Content-Length"]).toBe(String("head content".length));
|
|
expect(captured.body.length).toBe(0);
|
|
});
|
|
|
|
it("slow path serves HEAD without body for compressed response", async () => {
|
|
await writeFile(clientDir, "_next/static/head-slow-comp-ccc333.js", "compress me");
|
|
|
|
const req = mockReq("br", undefined, "HEAD");
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/head-slow-comp-ccc333.js",
|
|
true,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers["Content-Encoding"]).toBe("br");
|
|
expect(captured.body.length).toBe(0);
|
|
});
|
|
|
|
// ── URL-encoded characters in path ─────────────────────────────
|
|
//
|
|
// Regression test for https://github.com/cloudflare/vinext/issues/1472
|
|
//
|
|
// Browser requests for CSS files emitted with non-URL-safe characters in
|
|
// their filename (e.g. spaces, `@`, `#`) arrive percent-encoded. The
|
|
// static-asset server must decode the path before looking it up against
|
|
// the on-disk filename, otherwise the asset 404s and the page renders
|
|
// without its stylesheet.
|
|
//
|
|
// Matches Next.js test/e2e/app-dir/resource-url-encoding.
|
|
|
|
it("serves a CSS file requested via a percent-encoded URL", async () => {
|
|
const cssContent = "body { background: rgb(0, 0, 255); }\n";
|
|
// File on disk has literal characters (spaces, @) in its name; the
|
|
// emitted stylesheet URL percent-encodes them.
|
|
await writeFile(clientDir, "_next/static/css/my@style with spaces.css", cssContent);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq();
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/css/my%40style%20with%20spaces.css",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers["Content-Type"]).toBe("text/css");
|
|
expect(captured.body.toString()).toBe(cssContent);
|
|
});
|
|
|
|
it("slow path serves a CSS file requested via a percent-encoded URL", async () => {
|
|
const cssContent = "body { background: rgb(0, 0, 255); }\n";
|
|
await writeFile(clientDir, "_next/static/css/my@style with spaces.css", cssContent);
|
|
|
|
const req = mockReq();
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/css/my%40style%20with%20spaces.css",
|
|
false,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers["Content-Type"]).toBe("text/css");
|
|
expect(captured.body.toString()).toBe(cssContent);
|
|
});
|
|
|
|
it("serves a CSS file whose URL was decoded upstream (literal chars in pathname)", async () => {
|
|
// Simulates the Pages/App Router flow: the request pipeline runs
|
|
// normalizePathnameForRouteMatchStrict before tryServeStatic, which
|
|
// decodes each segment. By the time tryServeStatic is called, the
|
|
// pathname may contain literal special chars (no percent signs) — the
|
|
// cache still has to match against the literal on-disk filename.
|
|
const cssContent = "body { background: rgb(0, 0, 255); }\n";
|
|
await writeFile(clientDir, "_next/static/css/my@style with spaces.css", cssContent);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq();
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/css/my@style with spaces.css",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers["Content-Type"]).toBe("text/css");
|
|
expect(captured.body.toString()).toBe(cssContent);
|
|
});
|
|
|
|
// ── Malformed pathname handling ─────────────────────────────────
|
|
|
|
it("returns false for malformed percent-encoded pathname", async () => {
|
|
await writeFile(clientDir, "_next/static/safe-ddd444.js", "safe");
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq();
|
|
const { res } = mockRes();
|
|
|
|
const served = await tryServeStatic(req, res, clientDir, "/%E0%A4%A", true, cache);
|
|
|
|
expect(served).toBe(false);
|
|
});
|
|
|
|
it("slow path returns false for malformed percent-encoded pathname", async () => {
|
|
const req = mockReq();
|
|
const { res } = mockRes();
|
|
|
|
const served = await tryServeStatic(req, res, clientDir, "/%E0%A4%A", false);
|
|
|
|
expect(served).toBe(false);
|
|
});
|
|
|
|
// ── Slow path 304 (conditional requests) ───────────────────────
|
|
|
|
it("slow path returns 304 for hashed asset with matching filename-hash ETag", async () => {
|
|
await writeFile(clientDir, "_next/static/etag-slow-abc123.js", "etag content");
|
|
|
|
// The slow path computes a filename-hash ETag for /assets/* files.
|
|
// Derive the expected ETag the same way etagFromFilenameHash does.
|
|
const req = mockReq(undefined, { "if-none-match": 'W/"abc123"' });
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/etag-slow-abc123.js",
|
|
true,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.body.length).toBe(0);
|
|
expect(captured.headers["ETag"]).toBe('W/"abc123"');
|
|
expect(captured.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable");
|
|
});
|
|
|
|
it("slow path returns 200 when ETag does not match", async () => {
|
|
await writeFile(clientDir, "_next/static/etag-slow-miss-xyz999.js", "fresh content");
|
|
|
|
const req = mockReq(undefined, { "if-none-match": 'W/"stale-etag"' });
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/etag-slow-miss-xyz999.js",
|
|
true,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.body.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("slow path 304 includes Vary: Accept-Encoding for compressible content", async () => {
|
|
await writeFile(clientDir, "_next/static/vary-slow-abc123.js", "vary content");
|
|
|
|
const req = mockReq(undefined, { "if-none-match": 'W/"abc123"' });
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/vary-slow-abc123.js", true);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers["Vary"]).toBe("Accept-Encoding");
|
|
});
|
|
|
|
it("slow path 304 omits Vary for non-compressible content (compress=false)", async () => {
|
|
await writeFile(clientDir, "photo.jpg", Buffer.alloc(100, 0xff));
|
|
|
|
// jpg mtime-based etag — we need to stat the file to get the right etag
|
|
const stat = await fsp.stat(path.join(clientDir, "photo.jpg"));
|
|
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
|
|
|
|
const req = mockReq(undefined, { "if-none-match": etag });
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/photo.jpg", false);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers["Vary"]).toBeUndefined();
|
|
});
|
|
});
|