Cut what a signed-in tab costs by two thirds
Two changes on the push path, both measured against a real Stalwart 0.16.20
with the container capped at 256 MiB and tabs added in steps of 200 until the
kernel killed it:
tabs held per tab of which native
before 1,665 133 KiB 81 KiB
pin upstream calls to STALWART_URL 3,400 58 KiB 8 KiB
+ raw push relay 4,979 37 KiB 10 KiB
Stalwart advertises absolute https URLs in every session, and the proxy
followed them -- so even with STALWART_URL naming a private plain-HTTP hop on
the same Docker network, every held push stream went out through TLS. That leg
is about 80 KiB of OpenSSL state per tab: native memory Node cannot see, which
is why neither the heap ceiling nor the stream buffer size ever moved the
number. absoluteUpstream() now keeps the path and query from the advertised
URL and the scheme, host and port from the configured one. A setup that must
reach Stalwart at an origin other than the one it was given sets
STALWART_FOLLOW_ADVERTISED_URLS=1.
With the transport out of the way, the fetch()-based relay was the next cost:
an undici Response, a web ReadableStream, a reader and Hono's stream bridge
held alive per tab, about 44 KiB of heap for a session that otherwise costs
4 KiB. relayPushRaw() pipes the upstream socket into the Node response and
tells the adapter the response is already sent. RAW_PUSH_RELAY=0 restores the
fetch path for comparison.
JMAP throughput is unchanged (2,383/s against 2,484/s at 50 users, inside
run-to-run noise); the relay does not touch that path. Verified that a push
stream through the raw relay delivers a StateChange while mail is written.
The install page's advice to set --max-old-space-size was measured in the same
runs and made no difference at all -- 3,400 tabs with it and without -- and
is withdrawn in the docs alongside this change.
This commit is contained in:
@@ -2,6 +2,9 @@ import { Hono } from "hono";
|
|||||||
import type { Context, MiddlewareHandler } from "hono";
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||||
import { compress } from "hono/compress";
|
import { compress } from "hono/compress";
|
||||||
|
import { request as httpRequest } from "node:http";
|
||||||
|
import { request as httpsRequest } from "node:https";
|
||||||
|
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
||||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||||
@@ -700,6 +703,7 @@ 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.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
|
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
|
||||||
|
if (config.rawPushRelay) return relayPushRaw(c, url, session.authorization);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -790,6 +794,61 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
*/
|
*/
|
||||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold a push stream open with the least machinery that will do it.
|
||||||
|
*
|
||||||
|
* The fetch() version above builds an undici Response, a web ReadableStream,
|
||||||
|
* a reader, and Hono's stream-to-Node bridge for every tab, and keeps all of
|
||||||
|
* it alive for as long as the tab is open. Measured against a real Stalwart
|
||||||
|
* that is about 44 KiB of JavaScript heap per tab -- twelve times what the
|
||||||
|
* session itself costs -- and a signed-in tab is otherwise nothing but this
|
||||||
|
* one held connection. Here the upstream socket is piped straight into the
|
||||||
|
* Node response, so what stays resident per tab is two sockets and their
|
||||||
|
* small IncomingMessage/ServerResponse pair.
|
||||||
|
*
|
||||||
|
* Returns a Response Hono treats as already sent: the raw bindings are
|
||||||
|
* written to directly, and the returned value is never serialised.
|
||||||
|
*/
|
||||||
|
function relayPushRaw(c: Context<Env>, url: string, authorization: string): Response {
|
||||||
|
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
|
||||||
|
const target = new URL(url);
|
||||||
|
const req = (target.protocol === "https:" ? httpsRequest : httpRequest)(target, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { authorization, accept: "text/event-stream" },
|
||||||
|
});
|
||||||
|
const abort = () => req.destroy();
|
||||||
|
c.req.raw.signal.addEventListener("abort", abort);
|
||||||
|
out.on("close", abort);
|
||||||
|
req.on("response", (res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
out.writeHead(502, { "content-type": "application/json", "cache-control": "no-store" });
|
||||||
|
out.end(JSON.stringify({ error: "upstream_error" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.writeHead(200, {
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
"cache-control": "no-cache, no-transform",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"x-accel-buffering": "no",
|
||||||
|
});
|
||||||
|
out.flushHeaders();
|
||||||
|
res.pipe(out);
|
||||||
|
});
|
||||||
|
req.on("error", () => {
|
||||||
|
if (!out.headersSent) {
|
||||||
|
out.writeHead(502, { "content-type": "application/json", "cache-control": "no-store" });
|
||||||
|
out.end(JSON.stringify({ error: "upstream_error" }));
|
||||||
|
} else {
|
||||||
|
out.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
|
// Tells @hono/node-server the raw ServerResponse has been written to and
|
||||||
|
// must be left alone.
|
||||||
|
return RESPONSE_ALREADY_SENT;
|
||||||
|
}
|
||||||
|
|
||||||
function passthrough(res: Response): Response {
|
function passthrough(res: Response): Response {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
res.headers.forEach((v, k) => {
|
res.headers.forEach((v, k) => {
|
||||||
|
|||||||
@@ -74,3 +74,11 @@ test("the liveness probe is not compressed, since gzip would make it bigger", as
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(res.headers.get("content-encoding"), null);
|
assert.equal(res.headers.get("content-encoding"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("advertised upstream URLs are pinned to the configured origin", async () => {
|
||||||
|
const { absoluteUpstream } = await import("./upstream.js");
|
||||||
|
const pinned = absoluteUpstream("https://mail.public.example/jmap/eventsource/?types=*", "http://stalwart:8080");
|
||||||
|
assert.equal(pinned, "http://stalwart:8080/jmap/eventsource/?types=*");
|
||||||
|
// A relative URL still resolves against the base, as before.
|
||||||
|
assert.equal(absoluteUpstream("/jmap/", "http://stalwart:8080/"), "http://stalwart:8080/jmap/");
|
||||||
|
});
|
||||||
|
|||||||
@@ -298,6 +298,10 @@ export const config = {
|
|||||||
cookieName: env("COOKIE_NAME", "ihm_session"),
|
cookieName: env("COOKIE_NAME", "ihm_session"),
|
||||||
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||||
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
||||||
|
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
|
||||||
|
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
|
||||||
|
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */
|
||||||
|
followAdvertisedUrls: process.env.STALWART_FOLLOW_ADVERTISED_URLS === "1",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Config = typeof config;
|
export type Config = typeof config;
|
||||||
|
|||||||
+26
-1
@@ -288,9 +288,34 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
|
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
|
||||||
|
/**
|
||||||
|
* Resolve a URL Stalwart handed us against the server we were configured to
|
||||||
|
* talk to.
|
||||||
|
*
|
||||||
|
* Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl
|
||||||
|
* and the rest -- built from its public hostname, which is always https. A
|
||||||
|
* proxy that follows them takes every upstream call, and every held push
|
||||||
|
* stream, out through the public route even when STALWART_URL names a private
|
||||||
|
* plain-HTTP hop on the same network. Measured, that TLS leg is ~80 KiB of
|
||||||
|
* native OpenSSL state per signed-in tab: 60% of what a tab costs, and the
|
||||||
|
* whole difference between 1,665 and 3,680 tabs in 256 MiB.
|
||||||
|
*
|
||||||
|
* So by default only the path and query are taken from the advertised URL;
|
||||||
|
* scheme, host and port come from the configured base. That is what a proxy
|
||||||
|
* should have done all along -- the operator named the route on purpose.
|
||||||
|
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behaviour for a setup
|
||||||
|
* that genuinely needs to reach Stalwart at a different origin than the one
|
||||||
|
* it was given.
|
||||||
|
*/
|
||||||
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
|
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
|
||||||
try {
|
try {
|
||||||
return new URL(url, base).toString();
|
const resolved = new URL(url, base);
|
||||||
|
if (config.followAdvertisedUrls) return resolved.toString();
|
||||||
|
const pinned = new URL(base);
|
||||||
|
pinned.pathname = resolved.pathname;
|
||||||
|
pinned.search = resolved.search;
|
||||||
|
pinned.hash = "";
|
||||||
|
return pinned.toString();
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user