Merge pull request #916 from pikasTech/fix/issue912-cloud-web-static-compression
fix: gzip cloud web static assets
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gzip } from "node:zlib";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { isCloudWebSseRoute, proxyCloudApiRequest } from "./cloud-web-proxy.mjs";
|
||||
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
|
||||
@@ -11,6 +13,8 @@ const readOnlyRpcMethods = new Set([
|
||||
"audit.event.query",
|
||||
"evidence.record.query"
|
||||
]);
|
||||
const gzipAsync = promisify(gzip);
|
||||
const GZIP_MIN_BYTES = 1024;
|
||||
|
||||
export async function serveCloudWeb(options) {
|
||||
const server = createCloudWebServer(options);
|
||||
@@ -62,11 +66,14 @@ export function createCloudWebServer({
|
||||
const info = await stat(candidate);
|
||||
if (!info.isFile()) continue;
|
||||
const body = await readFile(candidate);
|
||||
const type = contentType(candidate);
|
||||
const encoded = await encodeStaticBody(request, body, type);
|
||||
response.writeHead(200, {
|
||||
"content-type": contentType(candidate),
|
||||
"content-length": body.length
|
||||
"content-type": type,
|
||||
...encoded.headers,
|
||||
"content-length": encoded.body.length
|
||||
});
|
||||
response.end(body);
|
||||
response.end(encoded.body);
|
||||
return;
|
||||
} catch {
|
||||
// Try the next root.
|
||||
@@ -207,5 +214,43 @@ function contentType(filePath) {
|
||||
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
||||
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
||||
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
|
||||
if (filePath.endsWith(".svg")) return "image/svg+xml; charset=utf-8";
|
||||
if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8";
|
||||
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
async function encodeStaticBody(request, body, type) {
|
||||
if (!isGzipCandidate(type, body.length) || !acceptsEncoding(request.headers["accept-encoding"], "gzip")) {
|
||||
return { body, headers: isCompressibleType(type) ? { "vary": "Accept-Encoding" } : {} };
|
||||
}
|
||||
const compressed = await gzipAsync(body, { level: 6 });
|
||||
return {
|
||||
body: compressed,
|
||||
headers: {
|
||||
"content-encoding": "gzip",
|
||||
"vary": "Accept-Encoding"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isGzipCandidate(type, sizeBytes) {
|
||||
return sizeBytes >= GZIP_MIN_BYTES && isCompressibleType(type);
|
||||
}
|
||||
|
||||
function isCompressibleType(type) {
|
||||
return /^(?:text\/|application\/json\b)/u.test(String(type || ""));
|
||||
}
|
||||
|
||||
function acceptsEncoding(header, encoding) {
|
||||
const value = Array.isArray(header) ? header.join(",") : String(header || "");
|
||||
for (const segment of value.split(",")) {
|
||||
const [name, ...params] = segment.trim().split(";");
|
||||
const token = name.trim().toLowerCase();
|
||||
if (token !== encoding && token !== "*") continue;
|
||||
const q = params.map((param) => param.trim().toLowerCase()).find((param) => param.startsWith("q="));
|
||||
if (q && Number.parseFloat(q.slice(2)) <= 0) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { createServer, request as httpRequest } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
|
||||
import { createCloudWebServer } from "./cloud-web-runtime.mjs";
|
||||
|
||||
@@ -219,6 +220,46 @@ test("cloud web serves access deep link through the React shell", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web serves compressible static assets with gzip", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-cloud-web-runtime-"));
|
||||
const appJs = `console.log(${JSON.stringify("fixture-static-asset ".repeat(512))});\n`;
|
||||
await writeFile(path.join(root, "app.js"), appJs, "utf8");
|
||||
const cloudWeb = createCloudWebServer({
|
||||
serviceId: "hwlab-cloud-web",
|
||||
roots: [root],
|
||||
cloudApiBaseUrl: "http://127.0.0.1:1",
|
||||
healthPayload: () => ({ status: "ok" }),
|
||||
sendJson(response, statusCode, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
response.writeHead(statusCode, {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload)
|
||||
});
|
||||
response.end(payload);
|
||||
}
|
||||
});
|
||||
await listen(cloudWeb);
|
||||
|
||||
try {
|
||||
const compressed = await requestRaw(`${serverUrl(cloudWeb)}/app.js`, { "accept-encoding": "gzip" });
|
||||
assert.equal(compressed.statusCode, 200);
|
||||
assert.equal(compressed.headers["content-type"], "text/javascript; charset=utf-8");
|
||||
assert.equal(compressed.headers["content-encoding"], "gzip");
|
||||
assert.equal(compressed.headers.vary, "Accept-Encoding");
|
||||
assert.equal(gunzipSync(compressed.body).toString("utf8"), appJs);
|
||||
assert.ok(Number(compressed.headers["content-length"]) < Buffer.byteLength(appJs), "gzip payload should be smaller than raw app.js");
|
||||
|
||||
const identity = await requestRaw(`${serverUrl(cloudWeb)}/app.js`, { "accept-encoding": "identity" });
|
||||
assert.equal(identity.statusCode, 200);
|
||||
assert.equal(identity.headers["content-encoding"], undefined);
|
||||
assert.equal(identity.headers.vary, "Accept-Encoding");
|
||||
assert.equal(identity.body.toString("utf8"), appJs);
|
||||
} finally {
|
||||
await close(cloudWeb);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
@@ -236,3 +277,15 @@ function serverUrl(server) {
|
||||
const address = server.address();
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
function requestRaw(url, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(url, { method: "GET", headers }, (response) => {
|
||||
const chunks = [];
|
||||
response.on("data", (chunk) => chunks.push(chunk));
|
||||
response.on("end", () => resolve({ statusCode: response.statusCode, headers: response.headers, body: Buffer.concat(chunks) }));
|
||||
});
|
||||
request.once("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user