From dbe75557bf0d37e4ff92392962f60669287ac233 Mon Sep 17 00:00:00 2001 From: Patodo Date: Fri, 18 Sep 2026 11:58:57 +0800 Subject: [PATCH] perf(serve): give app resources validators and immutable caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App version files were sent with only a CSP header: no Cache-Control, no ETag, no Last-Modified, while the Platform Shell's own /_next/static assets already had them. A hashed bundle under /serve///assets/ therefore could not be cached or revalidated, so every visit re-downloaded it in full — a 570 KB bundle and a 308 KB stylesheet measured on one real app. Serve files from an app version directory through one helper that sets an ETag and Last-Modified, answers 304 on a matching If-None-Match, and marks a Vite-style content-hashed asset under assets/ immutable. Anything else (index.html, or a file the app itself shipped) keeps its name across versions, so it stays 'no-cache' and revalidates. Measured against a locally installed app on the shipped build: app JS 570,538 B, no validators -> same bytes + immutable + ETag app CSS 307,949 B, no validators -> same bytes + immutable + ETag app HTML -> no-cache + ETag, revalidates as 304 repeat request with If-None-Match -> 304, 0 bytes (was a full re-download) --- packages/server/src/routes/serve.ts | 39 ++++++++++++++++--- .../tests/integration/serve-edge.test.ts | 20 ++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/server/src/routes/serve.ts b/packages/server/src/routes/serve.ts index 11505bd..5b69eba 100644 --- a/packages/server/src/routes/serve.ts +++ b/packages/server/src/routes/serve.ts @@ -1,6 +1,7 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import crypto from "node:crypto"; import fs from "node:fs"; +import { createReadStream } from "node:fs"; import path from "node:path"; import { getPageDir, readPageMeta, readDbConfig } from "../plugins/storage.js"; import { pushPageView } from "../lib/request-logger.js"; @@ -267,8 +268,7 @@ export async function serveRoutes(app: FastifyInstance, options: { webRoot?: str const versionDir = path.join(getPageDir(dataDir(), userId, name), "versions", `v${version}`); const indexPath = path.join(versionDir, "index.html"); if (fs.existsSync(indexPath)) { - reply.header("Content-Security-Policy", CSP_HEADER); - return reply.type("text/html").send(fs.readFileSync(indexPath)); + return sendAppFile(req, reply, indexPath, "index.html"); } return reply.status(404).send({ success: false, error: "index.html not found" }); }, @@ -444,15 +444,13 @@ export async function serveRoutes(app: FastifyInstance, options: { webRoot?: str let filePath = path.join(versionDir, restPath || "index.html"); if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { - reply.header("Content-Security-Policy", CSP_HEADER); - return reply.type(getMimeType(filePath)).send(fs.readFileSync(filePath)); + return sendAppFile(req, reply, filePath, restPath); } // SPA fallback: if not a static asset (no extension), serve index.html const indexPath = path.join(versionDir, "index.html"); if (fs.existsSync(indexPath) && !restPath.includes(".")) { - reply.header("Content-Security-Policy", CSP_HEADER); - return reply.type("text/html").send(fs.readFileSync(indexPath)); + return sendAppFile(req, reply, indexPath, restPath); } return reply.status(404).send({ success: false, error: "File not found" }); @@ -460,6 +458,35 @@ export async function serveRoutes(app: FastifyInstance, options: { webRoot?: str ); } +/** + * Vite emits bundled assets as `assets/-.`, so the URL + * already changes whenever the bytes change. Anything else under an app version + * directory (index.html, or a file the app itself shipped) keeps its name across + * versions and must revalidate instead. + */ +function isImmutableAppAsset(restPath: string): boolean { + return /(?:^|\/)assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/.test(restPath); +} + +/** + * Serves one file from an app version directory with the validators the platform + * shell already has and app resources used to lack: without them every visit + * re-downloaded the whole bundle, and a revalidation could not answer 304. + */ +function sendAppFile(req: FastifyRequest, reply: FastifyReply, filePath: string, restPath: string): FastifyReply { + const stat = fs.statSync(filePath); + const etag = `W/"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`; + reply.header("ETag", etag); + reply.header("Last-Modified", stat.mtime.toUTCString()); + reply.header("Cache-Control", isImmutableAppAsset(restPath) ? "public, max-age=31536000, immutable" : "no-cache"); + reply.header("Content-Security-Policy", CSP_HEADER); + const ifNoneMatch = req.headers["if-none-match"]; + if (typeof ifNoneMatch === "string" && ifNoneMatch.split(",").some((value) => value.trim() === etag)) { + return reply.status(304).send(); + } + return reply.type(getMimeType(filePath)).send(createReadStream(filePath)); +} + function injectNativeShellMetadata(html: string, userId: string, name: string): string { const resourceBase = `/serve/${userId}/${name}/`; const marker = [ diff --git a/packages/server/tests/integration/serve-edge.test.ts b/packages/server/tests/integration/serve-edge.test.ts index 31e2305..aebc8bb 100644 --- a/packages/server/tests/integration/serve-edge.test.ts +++ b/packages/server/tests/integration/serve-edge.test.ts @@ -43,10 +43,12 @@ describe("Serve edge cases", () => { body += `--${boundary}\r\nContent-Disposition: form-data; name="filepath_1"\r\n\r\nassets/main.js\r\n`; body += `--${boundary}\r\nContent-Disposition: form-data; name="filepath_2"\r\n\r\nassets/main.css\r\n`; body += `--${boundary}\r\nContent-Disposition: form-data; name="filepath_3"\r\n\r\nassets/pdf.worker.mjs\r\n`; + body += `--${boundary}\r\nContent-Disposition: form-data; name="filepath_4"\r\n\r\nassets/main-2f9a1c3b4d.js\r\n`; body += `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="index.html"\r\nContent-Type: text/html\r\n\r\n${html}\r\n`; body += `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="main.js"\r\nContent-Type: application/javascript\r\n\r\nconsole.log("hello");\r\n`; body += `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="main.css"\r\nContent-Type: text/css\r\n\r\nbody { margin: 0; }\r\n`; body += `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="pdf.worker.mjs"\r\nContent-Type: application/javascript\r\n\r\nexport const workerVersion = "test";\r\n`; + body += `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="main-2f9a1c3b4d.js"\r\nContent-Type: application/javascript\r\n\r\nconsole.log("hashed");\r\n`; body += `--${boundary}--\r\n`; await fetch(`${baseUrl}/api/upload`, { @@ -126,6 +128,24 @@ describe("Serve edge cases", () => { expect(html).toContain("data-localapp-app-resource-base"); }); + it("marks content-hashed app assets immutable and revalidates the rest", async () => { + // Break caught: app resources were served with no validators at all, so + // every visit re-downloaded the whole bundle instead of answering 304. + const hashed = await fetch(`${baseUrl}/serve/${userId}/${pageName}/assets/main-2f9a1c3b4d.js`); + expect(hashed.status).toBe(200); + expect(hashed.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(hashed.headers.get("etag")).toBeTruthy(); + + const plain = await fetch(`${baseUrl}/serve/${userId}/${pageName}/assets/main.js`); + expect(plain.headers.get("cache-control")).toBe("no-cache"); + const etag = plain.headers.get("etag"); + expect(etag).toBeTruthy(); + const revalidated = await fetch(`${baseUrl}/serve/${userId}/${pageName}/assets/main.js`, { + headers: { "If-None-Match": String(etag) }, + }); + expect(revalidated.status).toBe(304); + }); + it("未登录访问返回 Next.js Shell HTML(登录 UI 由客户端渲染)", async () => { const res = await fetch(`${baseUrl}/${userId}/${pageName}`); expect(res.status).toBe(200);