diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index 1449fee4..d5307a43 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -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; +} diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index 7b9d105a..96593138 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -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(); + }); +}