Push by subscription: hold no upstream connection per tab

A signed-in tab held two sockets: the browser's, and one from ihasmail to
Stalwart carrying that tab's push stream. The upstream one was most of what a
tab cost, and the only reason Stalwart's connection limit applied to ihasmail
at all.

RFC 8620 section 7.2 defines the other push transport: a PushSubscription,
where the server POSTs StateChange objects to a URL the client registers.
Stalwart 0.16.20 implements it. ihasmail now registers one subscription per
account at sign-in, and when Stalwart POSTs a change, fans it out to that
account's open tabs over the browser-facing streams it already holds. A tab
opens on the relay as before and is moved to fan-out the moment its account
verifies -- the upstream request is ended, the browser stream is untouched,
and nothing keeps a reference to what was torn down. After that there is no
upstream connection at all. The shapes are the RFC's; nothing here is taken
from any other client.

Measured at a 256 MiB cap over a private plain-HTTP route, against a real
Stalwart with 6,144 accounts verifying during the ramp and no failures:

                                 tabs   client   Stalwart   system  KiB/tab
  raw relay (before)            5,000     48.2       46.4     94.6
  push by subscription          6,144     33.3        4.8     38.0
  a direct-to-server client   12,389      4.8       53.8     58.6

Descriptors per tab: one, the browser's. Stalwart pays 4.8 KiB per tab and
holds no connection for it, so its per-listener connection limit no longer
applies to ihasmail. What remains per tab on the client is Node's cost for a
held HTTP/1.1 connection.

PUSH_URL is the https origin Stalwart can reach ihasmail at. The RFC requires
https and Stalwart enforces it, so Stalwart must trust that certificate: a
public TLS front already does; a private segment needs an internal CA in
Stalwart's trust store. An account whose subscription cannot be verified
stays on the relay, so nothing breaks -- only the saving needs the
certificate. PUSH_MODE=relay disables the subscription path entirely.

/api/push/:token accepts only a JSON body under 64 KiB for a known 32-byte
token, answers 200 or 404, and echoes nothing. /api/health reports how many
accounts are verified, pending or failed and how many tabs are on each path.
This commit is contained in:
2026-09-06 13:30:34 -07:00
parent f569f2cc7a
commit 2c47c0851c
4 changed files with 384 additions and 3 deletions
+35 -3
View File
@@ -5,6 +5,7 @@ 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 { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js";
import { getConnInfo } from "@hono/node-server/conninfo";
import { config } from "./config.js";
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
@@ -266,7 +267,22 @@ export function createApp(basePath = config.basePath): Hono<Env> {
const api = new Hono<Env>();
api.use("*", csrfGuard);
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version}));
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
/*
* Stalwart's push delivery. Authenticated by the token in the path -- 32
* random bytes, one per account, known only to us and to Stalwart -- and by
* nothing else, since Stalwart carries no credential when it POSTs. An
* unknown token is a 404 that looks like any other. See push.ts.
*/
app.post(`${basePath}/api/push/:token`, async (c) => {
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.body(null, 415);
const len = Number(c.req.header("content-length") ?? "0");
if (!len || len > 64 * 1024) return c.body(null, 413);
let body: unknown;
try { body = await c.req.json(); } catch { return c.body(null, 400); }
return c.body(null, (await pushReceive(c.req.param("token"), body)) as 200 | 400 | 404 | 500);
});
api.get("/config", (c) =>
@@ -351,6 +367,10 @@ export function createApp(basePath = config.basePath): Hono<Env> {
ip,
});
setSessionCookie(c, cookie, session.remember);
// Start the account's push subscription now, so it is usually verified
// by the time the browser opens its stream. See push.ts.
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
if (mailAccount) pushPrepare(session.username, mailAccount, session.authorization);
const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) {
@@ -727,7 +747,18 @@ export function createApp(basePath = config.basePath): Hono<Env> {
try {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
if (config.rawPushRelay) return relayPushRaw(c, url, session.authorization);
// Subscribe mode: if this account's subscription is verified, the tab is
// served by fan-out and holds nothing upstream. Otherwise it gets its own
// relay, and is moved to fan-out the moment the account verifies.
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
if (accountId && pushAttach(session.username, accountId, session.authorization, out)) {
out.writeHead(200, SSE_HEADERS);
out.flushHeaders();
out.write(": subscribed\n\n");
return RESPONSE_ALREADY_SENT;
}
if (config.rawPushRelay) return relayPushRaw(c, url, session.authorization, session.username);
const controller = new AbortController();
c.req.raw.signal.addEventListener("abort", () => controller.abort());
const res = await fetch(url, {
@@ -840,7 +871,7 @@ const SSE_HEADERS = {
"x-accel-buffering": "no",
} as const;
function relayPushRaw(c: Context<Env>, url: string, authorization: string): Response {
function relayPushRaw(c: Context<Env>, url: string, authorization: string, username?: 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, {
@@ -878,6 +909,7 @@ function relayPushRaw(c: Context<Env>, url: string, authorization: string): Resp
req.on("error", () => {});
req.destroy();
};
if (username) pushAttachRelay(username, out, migrate);
req.on("response", (res) => {
if (migrated) { res.destroy(); return; }
if (res.statusCode !== 200) { res.resume(); fail(); return; }