Express
Mount better-push on Express, and the three things that decide whether it works.
push.handler is a (Request) => Promise<Response> function. Express speaks
IncomingMessage/ServerResponse, so the integration is one bridge - the same
one @better-push/core/node, NestJS and the CLI studio all use.
import express from "express";
import { toExpressHandler } from "@better-push/core/express";
import { push } from "./push";
const app = express();
app.use(express.json());
app.all("/api/push/*splat", toExpressHandler(push));
app.listen(3000);That is the file this project's conformance suite boots and asserts against, so the example above is code that CI runs rather than code someone wrote once.
Express 4
Express 4 spells the wildcard "/api/push/*". Express 5 moved to
path-to-regexp v8, where a bare * is invalid and a named splat
("*splat") is required.
Three things that decide whether it works
Mount it before compression()
app.all("/api/push/*splat", toExpressHandler(push));
app.use(compression()); // after, alwaysCompression buffers, and GET /api/push/events is a Server-Sent Events stream
that never ends on its own. Compressed, it produces no output at all until the
stream closes - which is to say, the in-app feed silently stops updating and
nothing anywhere reports an error.
basePath must match the mount path
export const push = betterPush({ basePath: "/api/push", /* ... */ });
app.all("/api/push/*splat", toExpressHandler(push));The router matches on the full pathname, not on what Express stripped off
before calling the handler. Mounting at /push while basePath is /api/push
answers 404 for every endpoint.
A body parser is tolerated, not required
express.json() consumes the request stream. The bridge notices - req.body
is present - and re-serializes it, so a globally mounted parser is fine.
The one difference: express.json() rejects malformed JSON itself, with its own
400, before better-push sees the request. Mount the push routes before the
parser if you want better-push's error envelope for that case.
Streaming and disconnects
The bridge streams the response body straight to the socket - it never buffers
through arrayBuffer() - and wires an AbortController to the client
disconnecting. Both exist for the SSE route: without the first the feed hangs,
and without the second every disconnected client leaks a stream registration
until its lifetime cap.
It also sets x-accel-buffering: no on event streams, which is what stops nginx
from re-introducing the buffering Express was told not to do.
Behind a proxy
The bridge builds the request URL from x-forwarded-proto and Host. Override
it when your proxy does not set those:
toExpressHandler(push, { origin: "https://app.example.com" });Plain node:http
Skip Express entirely if you have nothing else to mount:
import { createServer } from "node:http";
import { toNodeHandler } from "@better-push/core/node";
createServer(toNodeHandler(push)).listen(3000);