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:
+35
-3
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user