mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
f517288f40
* fix(server): reject unsupported static asset methods * fix(server): route public asset mutations to 405 * chore: retrigger CI * fix(server): preserve static method parity across runtimes * fix(server): discard static probe responses safely * fix(server): sanitize static method responses * fix(server): preserve static method status * fix(server): strip stale static range headers * fix(server): preserve static rewrite ordering * fix(dev): cache public file routes * fix(dev): harden cached public route handling * fix(worker): avoid unnecessary asset probes * fix(server): preserve static asset lookup coverage * fix(build): keep public path encoder internal --------- Co-authored-by: James <james@eli.cx>
1507 lines
55 KiB
TypeScript
1507 lines
55 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 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";
|
|
|
|
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 });
|
|
});
|
|
|
|
it("returns 405 with Allow for unsupported methods on cached assets", async () => {
|
|
await writeFile(clientDir, "_next/static/app-abc123.js", "console.log('asset')");
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq(undefined, undefined, "POST");
|
|
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.status).toBe(405);
|
|
expect(captured.headers.Allow).toBe("GET, HEAD");
|
|
expect(captured.body.toString()).toBe("Method Not Allowed");
|
|
});
|
|
|
|
it("returns 405 with Allow for unsupported methods on uncached assets", async () => {
|
|
await writeFile(clientDir, "robots.txt", "User-agent: *");
|
|
const req = mockReq(undefined, undefined, "DELETE");
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(req, res, clientDir, "/robots.txt", true, undefined, {
|
|
"X-From-Middleware": "preserved",
|
|
"Set-Cookie": ["a=1", "b=2"],
|
|
"Content-Encoding": "gzip",
|
|
"Content-Range": "bytes 0-17/18",
|
|
"Content-Type": "application/wrong",
|
|
});
|
|
await captured.ended;
|
|
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(405);
|
|
expect(captured.headers.Allow).toBe("GET, HEAD");
|
|
expect(captured.headers["X-From-Middleware"]).toBe("preserved");
|
|
expect(captured.headers["Set-Cookie"]).toEqual(["a=1", "b=2"]);
|
|
expect(captured.headers["Content-Encoding"]).toBeUndefined();
|
|
expect(captured.headers["Content-Range"]).toBeUndefined();
|
|
expect(captured.headers["Content-Type"]).toBe("text/plain; charset=utf-8");
|
|
expect(captured.body.toString()).toBe("Method Not Allowed");
|
|
});
|
|
|
|
it("does not turn missing assets into method errors", async () => {
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq(undefined, undefined, "POST");
|
|
const { res } = mockRes();
|
|
|
|
await expect(
|
|
tryServeStatic(req, res, clientDir, "/_next/static/missing.js", true, cache),
|
|
).resolves.toBe(false);
|
|
await expect(tryServeStatic(req, res, clientDir, "/public-missing.txt", true)).resolves.toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
// ── 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; charset=utf-8");
|
|
// 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'");
|
|
});
|
|
|
|
it("does not vary cached files without precompressed variants", async () => {
|
|
await writeFile(clientDir, "photo.png", Buffer.alloc(100));
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("gzip");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/photo.png", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.headers.Vary).toBeUndefined();
|
|
});
|
|
|
|
// ── 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 a strong If-None-Match matches a weak 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;
|
|
expect(etag).toMatch(/^W\//);
|
|
|
|
const req = mockReq(undefined, { "if-none-match": etag.slice(2) });
|
|
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("returns 304 when a cached asset has not changed since If-Modified-Since", async () => {
|
|
await writeFile(clientDir, "conditional.txt", "cached content");
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const entry = cache.lookup("/conditional.txt")!;
|
|
const req = mockReq(undefined, {
|
|
"if-modified-since": entry.original.headers["Last-Modified"],
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/conditional.txt", true, cache);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers["Last-Modified"]).toBe(entry.original.headers["Last-Modified"]);
|
|
});
|
|
|
|
it("honors Cache-Control: no-cache when a cached asset ETag matches", async () => {
|
|
await writeFile(clientDir, "forced-revalidation.txt", "cached content");
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const entry = cache.lookup("/forced-revalidation.txt")!;
|
|
const req = mockReq(undefined, {
|
|
"cache-control": "no-cache",
|
|
"if-none-match": entry.etag,
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/forced-revalidation.txt", true, cache);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.body.toString()).toBe("cached content");
|
|
});
|
|
|
|
it("evaluates cached preconditions before Range", async () => {
|
|
await writeFile(clientDir, "conditional-range.txt", "0123456789");
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const entry = cache.lookup("/conditional-range.txt")!;
|
|
const req = mockReq(undefined, {
|
|
"if-none-match": entry.etag,
|
|
range: "bytes=0-2",
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/conditional-range.txt", true, cache);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers["Content-Range"]).toBeUndefined();
|
|
expect(captured.body).toHaveLength(0);
|
|
});
|
|
|
|
it("returns 412 before evaluating a cached Range when If-Match fails", async () => {
|
|
await writeFile(clientDir, "if-match-range.txt", "0123456789");
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq(undefined, {
|
|
"if-match": '"different"',
|
|
range: "bytes=0-2",
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/if-match-range.txt", true, cache);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(412);
|
|
expect(captured.headers["Content-Type"]).toBe("text/plain; charset=utf-8");
|
|
expect(captured.headers["Accept-Ranges"]).toBe("bytes");
|
|
expect(captured.headers["Content-Range"]).toBeUndefined();
|
|
expect(captured.body).toHaveLength(0);
|
|
});
|
|
|
|
it("returns 405 before evaluating conditions on an unsupported cached request", async () => {
|
|
await writeFile(clientDir, "unsafe-conditional.txt", "cached content");
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const entry = cache.lookup("/unsafe-conditional.txt")!;
|
|
const req = mockReq(undefined, { "if-none-match": entry.etag }, "POST");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/unsafe-conditional.txt", true, cache);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(405);
|
|
expect(captured.headers.Allow).toBe("GET, HEAD");
|
|
expect(captured.body.toString()).toBe("Method Not Allowed");
|
|
});
|
|
|
|
it("evaluates conditions against validators overridden by response headers", async () => {
|
|
await writeFile(clientDir, "custom-validator.txt", "cached content");
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq(undefined, { "if-none-match": '"custom"' });
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/custom-validator.txt", true, cache, {
|
|
etag: '"custom"',
|
|
});
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers.etag).toBe('"custom"');
|
|
expect(captured.body).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores If-Unmodified-Since when a cached response overrides Last-Modified invalidly", async () => {
|
|
await writeFile(clientDir, "invalid-last-modified.txt", "cached content");
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq(undefined, {
|
|
"if-unmodified-since": "Thu, 01 Jan 1970 00:00:00 GMT",
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/invalid-last-modified.txt", true, cache, {
|
|
"Last-Modified": "not-a-date",
|
|
});
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.body.toString()).toBe("cached content");
|
|
});
|
|
|
|
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; charset=utf-8");
|
|
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");
|
|
});
|
|
|
|
it("prefers the available variant with the highest client q-value", async () => {
|
|
const jsContent = "const weighted = true;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/weighted-jjj000.js", jsContent);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/weighted-jjj000.js.br",
|
|
zlib.brotliCompressSync(Buffer.from(jsContent)),
|
|
);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/weighted-jjj000.js.gz",
|
|
zlib.gzipSync(Buffer.from(jsContent)),
|
|
);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("br;q=0.1, gzip;q=1");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/weighted-jjj000.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.headers["Content-Encoding"]).toBe("gzip");
|
|
});
|
|
|
|
it("honors an RFC-valid trailing-dot refusal over a wildcard", async () => {
|
|
const jsContent = "const trailingDot = true;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/trailing-dot-kkk111.js", jsContent);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/trailing-dot-kkk111.js.br",
|
|
zlib.brotliCompressSync(Buffer.from(jsContent)),
|
|
);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/trailing-dot-kkk111.js.gz",
|
|
zlib.gzipSync(Buffer.from(jsContent)),
|
|
);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("*;q=0.8, br;q=0., identity;q=0.2");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/trailing-dot-kkk111.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.headers["Content-Encoding"]).toBe("gzip");
|
|
});
|
|
|
|
it("falls back to cached identity when every content coding is refused", async () => {
|
|
const jsContent = "const unacceptable = true;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/unacceptable-lll222.js", jsContent);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/unacceptable-lll222.js.br",
|
|
zlib.brotliCompressSync(Buffer.from(jsContent)),
|
|
);
|
|
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("*;q=0");
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/unacceptable-lll222.js",
|
|
true,
|
|
cache,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers.Vary).toBe("Accept-Encoding");
|
|
expect(captured.body.toString()).toBe(jsContent);
|
|
});
|
|
|
|
it("merges Accept-Encoding into Vary on a cached identity fallback", async () => {
|
|
const jsContent = "const vary406 = true;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/vary-406-mmm333.js", jsContent);
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("*;q=0");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/vary-406-mmm333.js", true, cache, {
|
|
Vary: "RSC",
|
|
});
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers.Vary).toBe("RSC");
|
|
});
|
|
|
|
it("serves an accepted cached variant when identity is only implicit", async () => {
|
|
const jsContent = "const identityPreferred = true;\n".repeat(200);
|
|
await writeFile(clientDir, "_next/static/identity-nnn444.js", jsContent);
|
|
await writeFile(
|
|
clientDir,
|
|
"_next/static/identity-nnn444.js.br",
|
|
zlib.brotliCompressSync(Buffer.from(jsContent)),
|
|
);
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const req = mockReq("br;q=0.5");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/identity-nnn444.js", true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.headers["Content-Encoding"]).toBe("br");
|
|
expect(captured.headers.Vary).toBe("Accept-Encoding");
|
|
expect(zlib.brotliDecompressSync(captured.body).toString()).toBe(jsContent);
|
|
});
|
|
|
|
it("returns cached 304 via identity fallback when codings are refused", async () => {
|
|
const relativePath = "_next/static/conditional-ooo555.js";
|
|
await writeFile(clientDir, relativePath, "conditional cached");
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
const firstReq = mockReq();
|
|
const { res: firstRes, captured: firstCaptured } = mockRes();
|
|
await tryServeStatic(firstReq, firstRes, clientDir, `/${relativePath}`, true, cache);
|
|
await firstCaptured.ended;
|
|
|
|
const req = mockReq("*;q=0", { "if-none-match": firstCaptured.headers.ETag as string });
|
|
const { res, captured } = mockRes();
|
|
await tryServeStatic(req, res, clientDir, `/${relativePath}`, true, cache);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers.Vary).toBeUndefined();
|
|
});
|
|
|
|
it("serves cached ranges with conditional precedence and lossless large integers", async () => {
|
|
const relativePath = "_next/static/range-aaa111.js";
|
|
// Keep the compressed sidecar wire-beneficial so this still exercises
|
|
// range negotiation against an entry that varies by Accept-Encoding.
|
|
const content = "0123456789".repeat(100);
|
|
await writeFile(clientDir, relativePath, content);
|
|
await writeFile(clientDir, `${relativePath}.br`, zlib.brotliCompressSync(content));
|
|
const cache = await StaticFileCache.create(clientDir);
|
|
|
|
const initialReq = mockReq();
|
|
const { res: initialRes, captured: initial } = mockRes();
|
|
await tryServeStatic(initialReq, initialRes, clientDir, `/${relativePath}`, true, cache);
|
|
await initial.ended;
|
|
|
|
const conditionalReq = mockReq(undefined, {
|
|
range: "bytes=2-9007199254740992",
|
|
"if-none-match": initial.headers.ETag as string,
|
|
});
|
|
const { res: conditionalRes, captured: conditional } = mockRes();
|
|
await tryServeStatic(
|
|
conditionalReq,
|
|
conditionalRes,
|
|
clientDir,
|
|
`/${relativePath}`,
|
|
true,
|
|
cache,
|
|
);
|
|
await conditional.ended;
|
|
expect(conditional.status).toBe(304);
|
|
expect(conditional.body).toHaveLength(0);
|
|
|
|
const rangeReq = mockReq("br, gzip", { range: "bytes=2-9007199254740992" });
|
|
const { res: rangeRes, captured: range } = mockRes();
|
|
await tryServeStatic(rangeReq, rangeRes, clientDir, `/${relativePath}`, true, cache);
|
|
await range.ended;
|
|
expect(range.status).toBe(206);
|
|
expect(range.headers["Content-Range"]).toBe("bytes 2-999/1000");
|
|
expect(range.headers["Content-Length"]).toBe("998");
|
|
expect(range.headers["Content-Encoding"]).toBeUndefined();
|
|
expect(range.headers.Vary).toBe("Accept-Encoding");
|
|
expect(range.body.toString()).toBe(content.slice(2));
|
|
|
|
const unsatisfiableReq = mockReq(undefined, { range: "bytes=9007199254740992-" });
|
|
const { res: unsatisfiableRes, captured: unsatisfiable } = mockRes();
|
|
await tryServeStatic(
|
|
unsatisfiableReq,
|
|
unsatisfiableRes,
|
|
clientDir,
|
|
`/${relativePath}`,
|
|
true,
|
|
cache,
|
|
);
|
|
await unsatisfiable.ended;
|
|
expect(unsatisfiable.status).toBe(416);
|
|
expect(unsatisfiable.headers["Content-Type"]).toBe("application/javascript; charset=utf-8");
|
|
expect(unsatisfiable.headers["Content-Range"]).toBe("bytes */1000");
|
|
expect(unsatisfiable.body).toHaveLength(0);
|
|
|
|
const headReq = mockReq(undefined, { range: "bytes=2-5" }, "HEAD");
|
|
const { res: headRes, captured: head } = mockRes();
|
|
await tryServeStatic(headReq, headRes, clientDir, `/${relativePath}`, true, cache);
|
|
await head.ended;
|
|
expect(head.status).toBe(206);
|
|
expect(head.headers["Content-Range"]).toBe("bytes 2-5/1000");
|
|
expect(head.headers["Content-Length"]).toBe("4");
|
|
expect(head.body).toHaveLength(0);
|
|
});
|
|
|
|
// ── 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; charset=utf-8");
|
|
expect(captured.body.toString()).toBe("slow path content");
|
|
});
|
|
|
|
it("slow path rejects ranges for empty files and ignores invalid If-Range dates", async () => {
|
|
await writeFile(clientDir, "empty.txt", "");
|
|
const emptyReq = mockReq(undefined, { range: "bytes=-9007199254740992" });
|
|
const { res: emptyRes, captured: empty } = mockRes();
|
|
await tryServeStatic(emptyReq, emptyRes, clientDir, "/empty.txt", false);
|
|
await empty.ended;
|
|
expect(empty.status).toBe(416);
|
|
expect(empty.headers["Content-Type"]).toBe("text/plain; charset=utf-8");
|
|
expect(empty.headers["Content-Range"]).toBe("bytes */0");
|
|
|
|
await writeFile(clientDir, "if-range.txt", "0123456789");
|
|
const invalidDateReq = mockReq(undefined, {
|
|
range: "bytes=2-5",
|
|
"if-range": "Sun, 31 Feb 2099 00:00:00 GMT",
|
|
});
|
|
const { res: invalidDateRes, captured: invalidDate } = mockRes();
|
|
await tryServeStatic(invalidDateReq, invalidDateRes, clientDir, "/if-range.txt", false);
|
|
await invalidDate.ended;
|
|
expect(invalidDate.status).toBe(200);
|
|
expect(invalidDate.headers["Content-Range"]).toBeUndefined();
|
|
expect(invalidDate.body.toString()).toBe("0123456789");
|
|
|
|
const rangeReq = mockReq("br", { range: "bytes=2-5" });
|
|
const { res: rangeRes, captured: range } = mockRes();
|
|
await tryServeStatic(rangeReq, rangeRes, clientDir, "/if-range.txt", true);
|
|
await range.ended;
|
|
expect(range.status).toBe(206);
|
|
expect(range.headers.Vary).toBe("Accept-Encoding");
|
|
expect(range.headers["Content-Encoding"]).toBeUndefined();
|
|
expect(range.body.toString()).toBe("2345");
|
|
|
|
const headReq = mockReq(undefined, { range: "bytes=2-5" }, "HEAD");
|
|
const { res: headRes, captured: head } = mockRes();
|
|
await tryServeStatic(headReq, headRes, clientDir, "/if-range.txt", true);
|
|
await head.ended;
|
|
expect(head.status).toBe(206);
|
|
expect(head.headers["Content-Range"]).toBe("bytes 2-5/10");
|
|
expect(head.headers["Content-Length"]).toBe("4");
|
|
expect(head.body).toHaveLength(0);
|
|
});
|
|
|
|
it("returns 405 before evaluating conditions on an unsupported slow request", async () => {
|
|
await writeFile(clientDir, "unsafe-slow.txt", "slow content");
|
|
const req = mockReq(undefined, { "if-none-match": "*" }, "POST");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/unsafe-slow.txt", true);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(405);
|
|
expect(captured.headers.Allow).toBe("GET, HEAD");
|
|
expect(captured.body.toString()).toBe("Method Not Allowed");
|
|
});
|
|
|
|
it("slow path does not vary non-compressible files", async () => {
|
|
await writeFile(clientDir, "uncached-photo.png", Buffer.alloc(100));
|
|
const req = mockReq("gzip");
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/uncached-photo.png", true);
|
|
|
|
await captured.ended;
|
|
expect(captured.headers.Vary).toBeUndefined();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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
|
|
//
|
|
// 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; charset=utf-8");
|
|
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; charset=utf-8");
|
|
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; charset=utf-8");
|
|
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 304 for managed image with matching dot-hash ETag", async () => {
|
|
await writeFile(clientDir, "_next/static/media/photo.0123abcd.png", "image content");
|
|
|
|
const req = mockReq(undefined, { "if-none-match": 'W/"0123abcd"' });
|
|
const { res, captured } = mockRes();
|
|
|
|
const served = await tryServeStatic(
|
|
req,
|
|
res,
|
|
clientDir,
|
|
"/_next/static/media/photo.0123abcd.png",
|
|
false,
|
|
);
|
|
|
|
await captured.ended;
|
|
expect(served).toBe(true);
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.body.length).toBe(0);
|
|
expect(captured.headers["ETag"]).toBe('W/"0123abcd"');
|
|
expect(captured.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable");
|
|
});
|
|
|
|
it("slow path does not reuse dot-hash ETags for arbitrary static files", async () => {
|
|
const relativePath = "_next/static/config.deadbeef.json";
|
|
await writeFile(clientDir, relativePath, '{"version":1}');
|
|
|
|
const firstReq = mockReq();
|
|
const { res: firstRes, captured: firstCaptured } = mockRes();
|
|
await tryServeStatic(firstReq, firstRes, clientDir, `/${relativePath}`, false);
|
|
await firstCaptured.ended;
|
|
|
|
const firstEtag = firstCaptured.headers["ETag"] as string;
|
|
expect(firstCaptured.status).toBe(200);
|
|
expect(firstCaptured.body.toString()).toBe('{"version":1}');
|
|
expect(firstEtag).not.toBe('W/"deadbeef"');
|
|
|
|
await writeFile(clientDir, relativePath, '{"version":200}');
|
|
|
|
const secondReq = mockReq(undefined, { "if-none-match": firstEtag });
|
|
const { res: secondRes, captured: secondCaptured } = mockRes();
|
|
await tryServeStatic(secondReq, secondRes, clientDir, `/${relativePath}`, false);
|
|
await secondCaptured.ended;
|
|
|
|
expect(secondCaptured.status).toBe(200);
|
|
expect(secondCaptured.body.toString()).toBe('{"version":200}');
|
|
expect(secondCaptured.headers["ETag"]).not.toBe(firstEtag);
|
|
});
|
|
|
|
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");
|
|
expect(captured.headers["Content-Type"]).toBeUndefined();
|
|
expect(captured.headers["Accept-Ranges"]).toBeUndefined();
|
|
});
|
|
|
|
it("returns slow-path 304 via identity fallback when codings are refused", async () => {
|
|
await writeFile(clientDir, "_next/static/conditional-slow-pqr678.js", "conditional slow");
|
|
const req = mockReq("*;q=0", { "if-none-match": 'W/"pqr678"' });
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/conditional-slow-pqr678.js", true);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers.Vary).toBe("Accept-Encoding");
|
|
});
|
|
|
|
it("supports If-Modified-Since on the slow path", async () => {
|
|
await writeFile(clientDir, "conditional-slow.txt", "slow content");
|
|
const stat = await fsp.stat(path.join(clientDir, "conditional-slow.txt"));
|
|
const req = mockReq(undefined, {
|
|
"if-modified-since": new Date(stat.mtimeMs).toUTCString(),
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/conditional-slow.txt", true);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers["Last-Modified"]).toBe(new Date(stat.mtimeMs).toUTCString());
|
|
});
|
|
|
|
it("honors Cache-Control: no-cache on the slow path", async () => {
|
|
await writeFile(clientDir, "_next/static/no-cache-slow-abc123.js", "slow content");
|
|
const req = mockReq(undefined, {
|
|
"cache-control": "no-cache",
|
|
"if-none-match": 'W/"abc123"',
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/_next/static/no-cache-slow-abc123.js", true);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.body.toString()).toBe("slow content");
|
|
});
|
|
|
|
it("evaluates slow-path preconditions before Range", async () => {
|
|
const relativePath = "conditional-range-slow.txt";
|
|
await writeFile(clientDir, relativePath, "0123456789");
|
|
const stat = await fsp.stat(path.join(clientDir, relativePath));
|
|
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
|
|
const req = mockReq(undefined, {
|
|
"if-none-match": etag,
|
|
range: "bytes=0-2",
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, `/${relativePath}`, true);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers["Content-Range"]).toBeUndefined();
|
|
expect(captured.body).toHaveLength(0);
|
|
});
|
|
|
|
it("returns 412 before evaluating a slow-path Range when If-Unmodified-Since fails", async () => {
|
|
const relativePath = "if-unmodified-since-slow.txt";
|
|
await writeFile(clientDir, relativePath, "0123456789");
|
|
const stat = await fsp.stat(path.join(clientDir, relativePath));
|
|
const req = mockReq(undefined, {
|
|
"if-unmodified-since": new Date(stat.mtimeMs - 2_000).toUTCString(),
|
|
range: "bytes=0-2",
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, `/${relativePath}`, true);
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(412);
|
|
expect(captured.headers["Content-Type"]).toBe("text/plain; charset=utf-8");
|
|
expect(captured.headers["Accept-Ranges"]).toBe("bytes");
|
|
expect(captured.headers["Content-Range"]).toBeUndefined();
|
|
expect(captured.body).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores If-Unmodified-Since when a slow response overrides Last-Modified invalidly", async () => {
|
|
const relativePath = "invalid-last-modified-slow.txt";
|
|
await writeFile(clientDir, relativePath, "slow content");
|
|
const req = mockReq(undefined, {
|
|
"if-unmodified-since": "Thu, 01 Jan 1970 00:00:00 GMT",
|
|
});
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, `/${relativePath}`, true, undefined, {
|
|
"Last-Modified": "not-a-date",
|
|
});
|
|
await captured.ended;
|
|
|
|
expect(captured.status).toBe(200);
|
|
expect(captured.body.toString()).toBe("slow content");
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it("slow path 304 omits Vary for non-compressible content when compression is enabled", async () => {
|
|
await writeFile(clientDir, "photo-compress-enabled.jpg", Buffer.alloc(100, 0xff));
|
|
const stat = await fsp.stat(path.join(clientDir, "photo-compress-enabled.jpg"));
|
|
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
|
|
const req = mockReq("gzip", { "if-none-match": etag });
|
|
const { res, captured } = mockRes();
|
|
|
|
await tryServeStatic(req, res, clientDir, "/photo-compress-enabled.jpg", true);
|
|
|
|
await captured.ended;
|
|
expect(captured.status).toBe(304);
|
|
expect(captured.headers.Vary).toBeUndefined();
|
|
});
|
|
});
|