better-push
Integrate your stack

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.

src/server.ts
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, always

Compression 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);

On this page