diff --git a/packages/ui/certd-server/src/configuration.ts b/packages/ui/certd-server/src/configuration.ts index 35757f537..efd81f899 100644 --- a/packages/ui/certd-server/src/configuration.ts +++ b/packages/ui/certd-server/src/configuration.ts @@ -22,6 +22,7 @@ import * as upload from "@midwayjs/upload"; import { setLogger } from "@certd/acme-client"; import { HiddenMiddleware } from "./middleware/hidden.js"; import { shouldSetDefaultNoCache } from "./configuration-cache.js"; +import { compressResponse } from "./middleware/compression.js"; // import * as swagger from '@midwayjs/swagger'; //@ts-ignore // process.env.UV_THREADPOOL_SIZE = 2 @@ -90,6 +91,7 @@ export class MainConfiguration { origin: "*", }) ); + this.app.use(compressResponse); // // this.app.use(async (ctx, next) => { // // 只在返回 'index.html' 的时候设置 maxAge diff --git a/packages/ui/certd-server/src/middleware/compression.test.ts b/packages/ui/certd-server/src/middleware/compression.test.ts new file mode 100644 index 000000000..2afed65a2 --- /dev/null +++ b/packages/ui/certd-server/src/middleware/compression.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { shouldCompress } from "./compression.js"; + +function createContext(overrides: Record = {}) { + const headers: Record = { "Accept-Encoding": "gzip" }; + return { + method: "GET", + status: 200, + body: { data: "response" }, + request: { get: (name: string) => headers[name] }, + response: { get: (name: string) => headers[name] }, + ...overrides, + } as any; +} + +describe("compression middleware", () => { + it("compresses gzip-capable API responses", () => { + assert.equal(shouldCompress(createContext()), true); + }); + + it("skips responses without gzip support or downloads", () => { + assert.equal(shouldCompress(createContext({ request: { get: () => "br" } })), false); + assert.equal(shouldCompress(createContext({ response: { get: (name: string) => (name === "Content-Disposition" ? "attachment" : undefined) } })), false); + }); +}); diff --git a/packages/ui/certd-server/src/middleware/compression.ts b/packages/ui/certd-server/src/middleware/compression.ts new file mode 100644 index 000000000..38804dd51 --- /dev/null +++ b/packages/ui/certd-server/src/middleware/compression.ts @@ -0,0 +1,68 @@ +import { gzip } from "node:zlib"; +import { promisify } from "node:util"; +import { IMidwayKoaContext, NextFunction } from "@midwayjs/koa"; + +const gzipAsync = promisify(gzip); +const MIN_COMPRESS_SIZE = 1024; + +function acceptsGzip(header: string | undefined): boolean { + if (!header) { + return false; + } + const encodings = header.split(",").map(value => value.trim().split(";")); + const gzipEncoding = encodings.find(([encoding]) => encoding === "gzip") ?? encodings.find(([encoding]) => encoding === "*"); + if (!gzipEncoding) { + return false; + } + const quality = gzipEncoding[1]?.trim().replace(/^q=/, ""); + return quality !== "0"; +} + +function isCompressibleContentType(contentType: string | undefined): boolean { + if (!contentType) { + return true; + } + const type = contentType.split(";", 1)[0].trim().toLowerCase(); + return type.startsWith("text/") || type.includes("json") || type.includes("javascript") || type.includes("xml") || type.includes("svg"); +} + +export function shouldCompress(ctx: Pick): boolean { + if (ctx.method === "HEAD" || ctx.status === 204 || ctx.status === 304) { + return false; + } + if (ctx.response.get("Content-Encoding") || ctx.response.get("Content-Disposition")) { + return false; + } + if (!acceptsGzip(ctx.request.get("Accept-Encoding"))) { + return false; + } + if (!isCompressibleContentType(ctx.response.get("Content-Type"))) { + return false; + } + if (Buffer.isBuffer(ctx.body) && !ctx.response.get("Content-Type")) { + return false; + } + return typeof ctx.body === "string" || Buffer.isBuffer(ctx.body) || (ctx.body !== null && typeof ctx.body === "object"); +} + +export async function compressResponse(ctx: IMidwayKoaContext, next: NextFunction): Promise { + await next(); + if (!shouldCompress(ctx)) { + return; + } + + const contentType = ctx.response.get("Content-Type"); + const body = Buffer.isBuffer(ctx.body) ? ctx.body : Buffer.from(typeof ctx.body === "string" ? ctx.body : JSON.stringify(ctx.body)); + if (body.length < MIN_COMPRESS_SIZE) { + return; + } + + const compressed = await gzipAsync(body); + ctx.body = compressed; + if (contentType) { + ctx.response.set("Content-Type", contentType); + } + ctx.response.set("Content-Encoding", "gzip"); + ctx.response.set("Vary", "Accept-Encoding"); + ctx.response.remove("Content-Length"); +}