diff --git a/server/src/app.ts b/server/src/app.ts index f543a20..327f247 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -2,6 +2,9 @@ import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import { getCookie, setCookie, deleteCookie } from "hono/cookie"; 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 { config } from "./config.js"; import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js"; @@ -700,6 +703,7 @@ export function createApp(basePath = config.basePath): Hono { 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); const controller = new AbortController(); c.req.raw.signal.addEventListener("abort", () => controller.abort()); 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"]); +/** + * 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, 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 { const headers = new Headers(); res.headers.forEach((v, k) => { diff --git a/server/src/compress.test.ts b/server/src/compress.test.ts index 49da5e6..6e83167 100644 --- a/server/src/compress.test.ts +++ b/server/src/compress.test.ts @@ -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.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/"); +}); diff --git a/server/src/config.ts b/server/src/config.ts index b9d34bb..3119f0e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -298,6 +298,10 @@ export const config = { cookieName: env("COOKIE_NAME", "ihm_session"), staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)), 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; diff --git a/server/src/upstream.ts b/server/src/upstream.ts index 06c9361..40eebf8 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -288,9 +288,34 @@ export function localizeSession(s: UpstreamSession, extras: Record