Merge pull request #386 from Coffey-Labs/perf/server-static-and-caches
Precompress the bundle, validate the shell, and pass byte ranges on
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* Write a Brotli and a gzip copy beside every compressible file in a web build.
|
||||||
|
*
|
||||||
|
* The server used to gzip the bundle again on every request that asked for it,
|
||||||
|
* at a level chosen for speed. These are made once, at the level chosen for
|
||||||
|
* size, and `server/src/static.ts` hands one out when the browser accepts it.
|
||||||
|
* Brotli at 11 is about 15% smaller than gzip for this bundle, and too slow to
|
||||||
|
* do per request, which is why it was never offered.
|
||||||
|
*
|
||||||
|
* node scripts/precompress.mjs web/dist
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, extname } from "node:path";
|
||||||
|
import { brotliCompressSync, constants, gzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".html", ".svg", ".json", ".webmanifest", ".txt", ".wasm"]);
|
||||||
|
// Below this, the encoding costs more than it saves.
|
||||||
|
const MIN_BYTES = 1024;
|
||||||
|
|
||||||
|
function* files(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const p = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) yield* files(p);
|
||||||
|
else yield p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = process.argv[2];
|
||||||
|
if (!root) {
|
||||||
|
console.error("usage: precompress.mjs <dir>");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
let before = 0;
|
||||||
|
let after = 0;
|
||||||
|
for (const p of files(root)) {
|
||||||
|
if (!COMPRESSIBLE.has(extname(p)) || statSync(p).size < MIN_BYTES) continue;
|
||||||
|
const data = readFileSync(p);
|
||||||
|
const br = brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: data.length } });
|
||||||
|
writeFileSync(`${p}.br`, br);
|
||||||
|
writeFileSync(`${p}.gz`, gzipSync(data, { level: 9 }));
|
||||||
|
count++;
|
||||||
|
before += data.length;
|
||||||
|
after += br.length;
|
||||||
|
}
|
||||||
|
console.log(`precompressed ${count} files: ${(before / 1024).toFixed(0)} KB -> ${(after / 1024).toFixed(0)} KB brotli`);
|
||||||
@@ -116,6 +116,34 @@ test("attachments are kept out of the disk cache of a device that is not the per
|
|||||||
await res.arrayBuffer();
|
await res.arrayBuffer();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a download passes a byte range through, for viewers that read in pieces", async () => {
|
||||||
|
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello world" });
|
||||||
|
const { blobId } = (await up.json()) as { blobId: string };
|
||||||
|
const url = `/api/blob/a1/${blobId}/greeting.txt?accept=text/plain`;
|
||||||
|
const part = await app.request(url, { headers: { cookie, range: "bytes=0-4" } });
|
||||||
|
assert.equal(part.status, 206);
|
||||||
|
assert.equal(part.headers.get("content-range"), "bytes 0-4/11");
|
||||||
|
assert.equal(part.headers.get("accept-ranges"), "bytes");
|
||||||
|
assert.equal(await part.text(), "hello");
|
||||||
|
const whole = await app.request(url, { headers: { cookie } });
|
||||||
|
assert.equal(whole.status, 200);
|
||||||
|
assert.equal(await whole.text(), "hello world");
|
||||||
|
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
|
||||||
|
assert.equal(beyond.status, 416);
|
||||||
|
// Anything that is not a plain byte range is not passed on.
|
||||||
|
const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } });
|
||||||
|
assert.equal(odd.status, 200);
|
||||||
|
await odd.arrayBuffer();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upstream caches let go of sessions that have aged out", async () => {
|
||||||
|
const { sweepUpstreamCaches, upstreamCacheSizes } = await import("./upstream.js");
|
||||||
|
// Signed in above, so this session has an entry.
|
||||||
|
assert.ok(upstreamCacheSizes().sessions >= 1);
|
||||||
|
sweepUpstreamCaches(Date.now() + 60 * 60_000);
|
||||||
|
assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
test("an app password needs a name", async () => {
|
test("an app password needs a name", async () => {
|
||||||
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
|
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
|
||||||
assert.equal(res.status, 400);
|
assert.equal(res.status, 400);
|
||||||
|
|||||||
+15
-3
@@ -803,19 +803,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
|
||||||
|
// A PDF viewer or a video element asks for pieces; pass that on. A server
|
||||||
|
// that ignores it answers with the whole file, as it did before.
|
||||||
|
const range = c.req.header("range");
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||||
// on our behalf and hand back a decompressed body whose content-length
|
// on our behalf and hand back a decompressed body whose content-length
|
||||||
// header still describes the compressed one -- see forwardedContentLength.
|
// header still describes the compressed one -- see forwardedContentLength.
|
||||||
headers: { authorization: session.authorization, "accept-encoding": "identity" },
|
headers: { authorization: session.authorization, "accept-encoding": "identity", ...(range && /^bytes=[\d,\s-]+$/.test(range) ? { range } : {}) },
|
||||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||||
});
|
});
|
||||||
|
if (res.status === 416) return c.body(null, 416);
|
||||||
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||||
headers.set("Content-Type", type);
|
headers.set("Content-Type", type);
|
||||||
const cl = forwardedContentLength(res.headers);
|
const cl = forwardedContentLength(res.headers);
|
||||||
if (cl) headers.set("Content-Length", cl);
|
if (cl) headers.set("Content-Length", cl);
|
||||||
|
const partial = res.status === 206 && res.headers.get("content-range");
|
||||||
|
if (partial) headers.set("Content-Range", partial);
|
||||||
|
if (res.headers.get("accept-ranges") === "bytes") headers.set("Accept-Ranges", "bytes");
|
||||||
const safeInline = inline && isInlineSafe(type);
|
const safeInline = inline && isInlineSafe(type);
|
||||||
headers.set(
|
headers.set(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
@@ -840,8 +847,13 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
}
|
}
|
||||||
// Kept out of the browser's disk cache on a device that is not the
|
// Kept out of the browser's disk cache on a device that is not the
|
||||||
// person's own: signing out wipes what the app stores, not that.
|
// person's own: signing out wipes what the app stores, not that.
|
||||||
headers.set("Cache-Control", session.remember ? "private, max-age=3600" : "no-store");
|
/*
|
||||||
return new Response(res.body, { status: 200, headers });
|
* A blob id names its content -- the same id is the same bytes for good
|
||||||
|
* -- so on the reader's own device there is nothing to revalidate. On
|
||||||
|
* anyone else's, nothing is left in the disk cache at all.
|
||||||
|
*/
|
||||||
|
headers.set("Cache-Control", session.remember ? "private, max-age=31536000, immutable" : "no-store");
|
||||||
|
return new Response(res.body, { status: partial ? 206 : 200, headers });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return upstreamFailure(c, err);
|
return upstreamFailure(c, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,21 @@ export const server = createServer(async (req, res) => {
|
|||||||
const [, , , , blobId] = url.pathname.split("/");
|
const [, , , , blobId] = url.pathname.split("/");
|
||||||
const b = blobs.get(blobId ?? "");
|
const b = blobs.get(blobId ?? "");
|
||||||
if (!b) { res.writeHead(404); return res.end(); }
|
if (!b) { res.writeHead(404); return res.end(); }
|
||||||
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
|
const type = url.searchParams.get("accept") ?? b.type;
|
||||||
|
// One byte range, the way a PDF viewer or a video element asks for one.
|
||||||
|
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
|
||||||
|
if (m && (m[1] || m[2])) {
|
||||||
|
const size = b.data.length;
|
||||||
|
const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]));
|
||||||
|
const end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
|
||||||
|
if (start >= size || start > end) {
|
||||||
|
res.writeHead(416, { "content-range": `bytes */${size}` });
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
res.writeHead(206, { "content-type": type, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${size}`, "accept-ranges": "bytes" });
|
||||||
|
return res.end(b.data.subarray(start, end + 1));
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": type, "content-length": b.data.length, "accept-ranges": "bytes" });
|
||||||
return res.end(b.data);
|
return res.end(b.data);
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The bundle goes out compressed once, at build time, and anything revalidated
|
||||||
|
* can be answered with a 304.
|
||||||
|
*
|
||||||
|
* Before this the server gzipped the bundle again for every request that asked,
|
||||||
|
* never offered Brotli, and sent no validator for the shell -- so each reload's
|
||||||
|
* revalidation of index.html and sw.js downloaded them in full.
|
||||||
|
*/
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "ihasmail-precompressed-"));
|
||||||
|
mkdirSync(join(root, "assets"));
|
||||||
|
const js = `console.log(${JSON.stringify("x".repeat(4000))});\n`;
|
||||||
|
writeFileSync(join(root, "assets", "app-a1b2c3.js"), js);
|
||||||
|
writeFileSync(join(root, "assets", "app-a1b2c3.js.br"), brotliCompressSync(js));
|
||||||
|
writeFileSync(join(root, "assets", "app-a1b2c3.js.gz"), gzipSync(js));
|
||||||
|
writeFileSync(join(root, "assets", "plain-d4e5f6.js"), js);
|
||||||
|
// A copy left over from an older build of the same name must not be served.
|
||||||
|
writeFileSync(join(root, "assets", "stale-000000.js"), js);
|
||||||
|
writeFileSync(join(root, "assets", "stale-000000.js.br"), brotliCompressSync("old"));
|
||||||
|
const old = new Date(Date.now() - 60_000);
|
||||||
|
utimesSync(join(root, "assets", "stale-000000.js.br"), old, old);
|
||||||
|
writeFileSync(join(root, "sw.js"), "/* worker */\n");
|
||||||
|
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
||||||
|
|
||||||
|
process.env.STATIC_DIR = root;
|
||||||
|
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||||
|
const { createApp } = await import("./app.js");
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const get = (path: string, headers: Record<string, string> = {}) => app.request(path, { headers });
|
||||||
|
|
||||||
|
test("Brotli is served where the browser takes it", async () => {
|
||||||
|
const res = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, deflate, br" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("content-encoding"), "br");
|
||||||
|
assert.equal(res.headers.get("vary"), "Accept-Encoding");
|
||||||
|
assert.equal(res.headers.get("content-type"), "text/javascript; charset=utf-8");
|
||||||
|
assert.equal(brotliDecompressSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gzip where Brotli is not accepted, and nothing where neither is", async () => {
|
||||||
|
const gz = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, br;q=0" });
|
||||||
|
assert.equal(gz.headers.get("content-encoding"), "gzip");
|
||||||
|
assert.equal(gunzipSync(Buffer.from(await gz.arrayBuffer())).toString(), js);
|
||||||
|
const plain = await get("/assets/app-a1b2c3.js");
|
||||||
|
assert.equal(plain.headers.get("content-encoding"), null);
|
||||||
|
assert.equal(await plain.text(), js);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a file without a copy is compressed as before", async () => {
|
||||||
|
const res = await get("/assets/plain-d4e5f6.js", { "accept-encoding": "gzip" });
|
||||||
|
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||||
|
assert.equal(gunzipSync(Buffer.from(await res.arrayBuffer())).toString(), js);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a copy older than its file is ignored", async () => {
|
||||||
|
const res = await get("/assets/stale-000000.js", { "accept-encoding": "br" });
|
||||||
|
assert.notEqual(res.headers.get("content-encoding"), "br");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the shell and the worker answer a revalidation with 304", async () => {
|
||||||
|
for (const path of ["/", "/sw.js"]) {
|
||||||
|
const first = await get(path);
|
||||||
|
const etag = first.headers.get("etag");
|
||||||
|
assert.ok(etag, `${path} carries a validator`);
|
||||||
|
await first.arrayBuffer();
|
||||||
|
const again = await get(path, { "if-none-match": etag! });
|
||||||
|
assert.equal(again.status, 304, `${path} is not sent again`);
|
||||||
|
assert.equal(await again.text(), "");
|
||||||
|
const changed = await get(path, { "if-none-match": `"something-else"` });
|
||||||
|
assert.equal(changed.status, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
+76
-4
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import { stat, readFile } from "node:fs/promises";
|
import { stat, readFile } from "node:fs/promises";
|
||||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||||
@@ -77,9 +78,61 @@ export const APP_CSP = [
|
|||||||
"manifest-src 'self'",
|
"manifest-src 'self'",
|
||||||
].join("; ");
|
].join("; ");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* What a file is, for the purpose of "has it changed". The shell and the
|
||||||
|
* never-stale files are revalidated on every load; with no validator to send
|
||||||
|
* back, every revalidation downloaded the whole file again.
|
||||||
|
*/
|
||||||
|
function etagOf(size: number, mtimeMs: number): string {
|
||||||
|
return `W/"${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notModified(c: Context, etag: string): boolean {
|
||||||
|
const sent = c.req.header("if-none-match");
|
||||||
|
return Boolean(sent && sent.split(",").some((t) => t.trim() === etag || t.trim() === "*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The encodings a build can carry beside a file, best first. See
|
||||||
|
* scripts/precompress.mjs, which writes them.
|
||||||
|
*/
|
||||||
|
const PRECOMPRESSED: Array<{ token: string; suffix: string; encoding: string }> = [
|
||||||
|
{ token: "br", suffix: ".br", encoding: "br" },
|
||||||
|
{ token: "gzip", suffix: ".gz", encoding: "gzip" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function accepts(c: Context, token: string): boolean {
|
||||||
|
const header = c.req.header("accept-encoding") ?? "";
|
||||||
|
return header.split(",").some((part) => {
|
||||||
|
const [name, ...params] = part.trim().split(";");
|
||||||
|
if (name?.trim().toLowerCase() !== token) return false;
|
||||||
|
const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
|
||||||
|
return !q || Number(q.slice(2)) > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function staticHandler(root: string, basePath = ""): Handler {
|
export function staticHandler(root: string, basePath = ""): Handler {
|
||||||
const absRoot = resolve(root);
|
const absRoot = resolve(root);
|
||||||
let indexCache: { body: string; mtime: number } | null = null;
|
let indexCache: { body: string; mtime: number; etag: string } | null = null;
|
||||||
|
/** Which precompressed copies exist, per file and modification time. */
|
||||||
|
const variants = new Map<string, { mtime: number; found: Map<string, number> }>();
|
||||||
|
|
||||||
|
async function variantsOf(filePath: string, mtime: number): Promise<Map<string, number>> {
|
||||||
|
const known = variants.get(filePath);
|
||||||
|
if (known && known.mtime === mtime) return known.found;
|
||||||
|
const found = new Map<string, number>();
|
||||||
|
for (const v of PRECOMPRESSED) {
|
||||||
|
try {
|
||||||
|
const st = await stat(filePath + v.suffix);
|
||||||
|
// A copy older than the file it came from describes something else.
|
||||||
|
if (st.isFile() && st.mtimeMs >= mtime) found.set(v.suffix, st.size);
|
||||||
|
} catch {
|
||||||
|
/* none */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
variants.set(filePath, { mtime, found });
|
||||||
|
return found;
|
||||||
|
}
|
||||||
let mismatchWarned = false;
|
let mismatchWarned = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,13 +160,16 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
const p = join(absRoot, "index.html");
|
const p = join(absRoot, "index.html");
|
||||||
const st = await stat(p);
|
const st = await stat(p);
|
||||||
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
||||||
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
|
const body = await readFile(p, "utf8");
|
||||||
|
indexCache = { body, mtime: st.mtimeMs, etag: `"${createHash("sha256").update(body).digest("base64url").slice(0, 22)}"` };
|
||||||
mismatchWarned = false;
|
mismatchWarned = false;
|
||||||
}
|
}
|
||||||
warnOnBaseMismatch(indexCache.body);
|
warnOnBaseMismatch(indexCache.body);
|
||||||
c.header("Content-Type", "text/html; charset=utf-8");
|
c.header("Content-Type", "text/html; charset=utf-8");
|
||||||
c.header("Cache-Control", "no-cache");
|
c.header("Cache-Control", "no-cache");
|
||||||
c.header("Content-Security-Policy", APP_CSP);
|
c.header("Content-Security-Policy", APP_CSP);
|
||||||
|
c.header("ETag", indexCache.etag);
|
||||||
|
if (notModified(c, indexCache.etag)) return c.body(null, 304);
|
||||||
return c.body(indexCache.body);
|
return c.body(indexCache.body);
|
||||||
} catch {
|
} catch {
|
||||||
c.header("Content-Type", "text/plain; charset=utf-8");
|
c.header("Content-Type", "text/plain; charset=utf-8");
|
||||||
@@ -142,7 +198,8 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
if (!st.isFile()) return serveIndex(c);
|
if (!st.isFile()) return serveIndex(c);
|
||||||
const ext = extname(filePath).toLowerCase();
|
const ext = extname(filePath).toLowerCase();
|
||||||
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
||||||
c.header("Content-Length", String(st.size));
|
const etag = etagOf(st.size, st.mtimeMs);
|
||||||
|
c.header("ETag", etag);
|
||||||
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
||||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
||||||
@@ -151,8 +208,23 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
} else {
|
} else {
|
||||||
c.header("Cache-Control", "public, max-age=3600");
|
c.header("Cache-Control", "public, max-age=3600");
|
||||||
}
|
}
|
||||||
|
if (notModified(c, etag)) return c.body(null, 304);
|
||||||
|
// Serve a copy made at build time where the browser takes one.
|
||||||
|
let servePath = filePath;
|
||||||
|
let size = st.size;
|
||||||
|
const found = await variantsOf(filePath, st.mtimeMs);
|
||||||
|
if (found.size) {
|
||||||
|
c.header("Vary", "Accept-Encoding");
|
||||||
|
const pick = PRECOMPRESSED.find((v) => found.has(v.suffix) && accepts(c, v.token));
|
||||||
|
if (pick) {
|
||||||
|
servePath = filePath + pick.suffix;
|
||||||
|
size = found.get(pick.suffix)!;
|
||||||
|
c.header("Content-Encoding", pick.encoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.header("Content-Length", String(size));
|
||||||
if (c.req.method === "HEAD") return c.body(null);
|
if (c.req.method === "HEAD") return c.body(null);
|
||||||
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
|
const stream = Readable.toWeb(createReadStream(servePath)) as ReadableStream;
|
||||||
return c.body(stream);
|
return c.body(stream);
|
||||||
} catch {
|
} catch {
|
||||||
// SPA fallback for client-side routes (no file extension) only.
|
// SPA fallback for client-side routes (no file extension) only.
|
||||||
|
|||||||
@@ -233,6 +233,23 @@ export interface AccountInfo {
|
|||||||
|
|
||||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||||
const INFO_CACHE_MS = 30 * 60_000;
|
const INFO_CACHE_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Both caches are keyed by session, and used to lose an entry only when that
|
||||||
|
* session signed out or was refused -- not when it simply expired, which is how
|
||||||
|
* most sessions end. An entry past its age is never used again, so dropping
|
||||||
|
* those on a timer is all it takes to stop them accumulating.
|
||||||
|
*/
|
||||||
|
export function sweepUpstreamCaches(now = Date.now()): void {
|
||||||
|
for (const [id, v] of sessionCache) if (now - v.fetchedAt >= SESSION_CACHE_MS) sessionCache.delete(id);
|
||||||
|
for (const [id, v] of infoCache) if (now - v.fetchedAt >= INFO_CACHE_MS) infoCache.delete(id);
|
||||||
|
}
|
||||||
|
setInterval(() => sweepUpstreamCaches(), SESSION_CACHE_MS).unref();
|
||||||
|
|
||||||
|
/** How many sessions the caches hold; for tests. */
|
||||||
|
export function upstreamCacheSizes(): { sessions: number; info: number } {
|
||||||
|
return { sessions: sessionCache.size, info: infoCache.size };
|
||||||
|
}
|
||||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
"build": "tsc -p tsconfig.json --noEmit && vite build && node ../scripts/precompress.mjs dist",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
|
|||||||
Reference in New Issue
Block a user