R65 A1-A6:Session 稳定性与 409 资源错误修复
这个提交包含在:
@@ -78,6 +78,27 @@ const { initMetrics } = require("./observability");
|
||||
const { AppError } = require("./utils/AppError");
|
||||
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi");
|
||||
|
||||
const NO_CACHE_STATIC_FILES = new Set([
|
||||
"index.html",
|
||||
"sw.js",
|
||||
"manifest.webmanifest",
|
||||
"registerSW.js",
|
||||
]);
|
||||
|
||||
function setStaticCacheHeaders(res, filePath) {
|
||||
const normalizedPath = String(filePath).replace(/\\/g, "/");
|
||||
const fileName = normalizedPath.split("/").pop() ?? "";
|
||||
|
||||
if (NO_CACHE_STATIC_FILES.has(fileName)) {
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedPath.includes("/assets/") || /\/workbox-[^/]+\.js$/i.test(normalizedPath)) {
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
}
|
||||
}
|
||||
|
||||
async function buildApp() {
|
||||
const config = getConfig();
|
||||
|
||||
@@ -151,7 +172,9 @@ async function buildApp() {
|
||||
|
||||
if (config.serveClientDist) {
|
||||
await app.register(staticPlugin, {
|
||||
cacheControl: false,
|
||||
root: config.clientDistDir,
|
||||
setHeaders: setStaticCacheHeaders,
|
||||
});
|
||||
app.log.info({ dir: config.clientDistDir }, "Client dist static serving enabled");
|
||||
}
|
||||
@@ -172,6 +195,7 @@ async function buildApp() {
|
||||
request.headers.accept.includes("text/html") &&
|
||||
typeof reply.sendFile === "function"
|
||||
) {
|
||||
reply.header("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
return reply.type("text/html").sendFile("index.html");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const createPrismaClient = vi.fn(() => ({
|
||||
prisma: {
|
||||
$connect: vi.fn().mockResolvedValue(undefined),
|
||||
$disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
employee: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
pool: {
|
||||
end: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const startJobs = vi.fn(async () => ({ driver: "disabled" }));
|
||||
|
||||
vi.mock("../../src/prismaClient", () => ({
|
||||
createPrismaClient,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/jobs/jobSystem", () => ({
|
||||
startJobs,
|
||||
}));
|
||||
|
||||
const { buildApp } = require("../../src/app");
|
||||
|
||||
async function createClientDistFixture() {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-static-cache-"));
|
||||
await fs.mkdir(path.join(tempDir, "assets"), { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, "index.html"), "<!doctype html><html><body><div id=\"root\"></div></body></html>");
|
||||
await fs.writeFile(path.join(tempDir, "sw.js"), "self.addEventListener('install', () => undefined);");
|
||||
await fs.writeFile(path.join(tempDir, "manifest.webmanifest"), JSON.stringify({ name: "hyb-oa" }));
|
||||
await fs.writeFile(path.join(tempDir, "registerSW.js"), "export const register = () => undefined;");
|
||||
await fs.writeFile(path.join(tempDir, "workbox-abc123.js"), "self.__WB_MANIFEST = [];");
|
||||
await fs.writeFile(path.join(tempDir, "assets/index-abc123.js"), "console.log('asset');");
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
async function buildStaticApp(clientDistDir) {
|
||||
vi.stubEnv("NODE_ENV", "test");
|
||||
vi.stubEnv("JWT_SECRET", "test-secret");
|
||||
vi.stubEnv("SERVE_CLIENT_DIST", "true");
|
||||
vi.stubEnv("CLIENT_DIST_DIR", clientDistDir);
|
||||
vi.stubEnv("STORAGE_DIR", path.join(clientDistDir, "..", "storage"));
|
||||
vi.stubEnv("JOBS_ENABLED", "false");
|
||||
|
||||
return buildApp();
|
||||
}
|
||||
|
||||
describe("app static cache headers", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("serves shell files with no-cache and fingerprinted assets with immutable cache", async () => {
|
||||
const clientDistDir = await createClientDistFixture();
|
||||
const app = await buildStaticApp(clientDistDir);
|
||||
|
||||
try {
|
||||
const indexResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
const swResponse = await app.inject({ method: "GET", url: "/sw.js" });
|
||||
const manifestResponse = await app.inject({ method: "GET", url: "/manifest.webmanifest" });
|
||||
const registerResponse = await app.inject({ method: "GET", url: "/registerSW.js" });
|
||||
const assetResponse = await app.inject({ method: "GET", url: "/assets/index-abc123.js" });
|
||||
const workboxResponse = await app.inject({ method: "GET", url: "/workbox-abc123.js" });
|
||||
|
||||
expect(indexResponse.statusCode).toBe(200);
|
||||
expect(swResponse.statusCode).toBe(200);
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
expect(registerResponse.statusCode).toBe(200);
|
||||
expect(assetResponse.statusCode).toBe(200);
|
||||
expect(workboxResponse.statusCode).toBe(200);
|
||||
|
||||
expect(indexResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate");
|
||||
expect(swResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate");
|
||||
expect(manifestResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate");
|
||||
expect(registerResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate");
|
||||
expect(assetResponse.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
|
||||
expect(workboxResponse.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(clientDistDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户