bun-pg-listen
v0.1.1
Published
Zero-dependency Postgres LISTEN/NOTIFY for Bun.
Maintainers
Readme
bun-pg-listen
A zero-dependency polyfill for Postgres LISTEN / NOTIFY on Bun's Bun.sql driver — which doesn't implement either command yet. This library is intended to be temporary: once Bun ships native support, rip it out. Design goals: keep the public surface small, match Bun.sql's ergonomics, and make the eventual removal trivial.
- Zero runtime dependencies — pure Bun +
node:crypto. - SCRAM-SHA-256 authentication (RFC 7677). MD5 is intentionally not supported — Postgres 14+ defaults to SCRAM.
- Exponential-backoff reconnect with automatic re-
LISTENof every active channel beforeonReconnectfires. - TLS via
Bun.TCPSocket.upgradeTLS()—sslmode=require,verify-ca, andverify-fullall supported. - Byte-exact parity with the RFC 7677 SCRAM test vector.
Install
bun add bun-pg-listenRequires Bun ≥ 1.2 and Postgres ≥ 13 (SCRAM-SHA-256).
Usage
import { PgListener } from "bun-pg-listen";
const listener = new PgListener();
await listener.connect();
const unlisten = await listener.listen("page_updates", (payload) => {
console.log(payload);
});
await listener.notify(
"page_updates",
JSON.stringify({ pageId: "abc", op: "save" }),
);
await unlisten();
await listener.close();Connection config is resolved from env in the same order Bun.sql uses — POSTGRES_URL, DATABASE_URL, PGURL, PG_URL, TLS_POSTGRES_DATABASE_URL, TLS_DATABASE_URL, then individual PGHOST / PGPORT / PGUSER / PGPASSWORD / PGDATABASE. So the snippet above works unchanged on any machine that can already run Bun.sql.
Custom connection string
const listener = new PgListener(
"postgres://user:pass@host:5432/db?sslmode=require",
);Inline options
Mirrors Bun.sql's shape — pass fields directly or combine with a URL:
const listener = new PgListener({
hostname: "localhost",
port: 5432,
user: "postgres",
password: "postgres",
database: "app",
applicationName: "my-worker", // shows up in pg_stat_activity
tls: true,
});Verified TLS (verify-full / verify-ca)
sslmode=verify-full validates the certificate chain and hostname against Bun's bundled CA store — this is the right setting for most managed providers (Neon, Supabase, Railway, Fly, Crunchy Bridge all use public CAs):
const listener = new PgListener(
"postgres://user:[email protected]/app?sslmode=verify-full",
);For providers that ship a private CA (AWS RDS, GCP Cloud SQL, on-prem), pass it through tls:
const listener = new PgListener(
"postgres://user:[email protected]/app?sslmode=verify-full",
{
tls: { ca: await Bun.file("./rds-ca.pem").text() },
},
);verify-ca skips the hostname check (useful when connecting through an internal load balancer whose cert SAN doesn't match). Explicit tls fields override the sslmode-derived defaults.
Callbacks
const listener = new PgListener({
onConnect: () => console.log("connected"),
onReconnect: () => replayMissedUpdates(), // fired after all channels are re-LISTENed
onError: (err) => log.warn(err),
onNotice: (fields) => log.info(fields.M),
});When Bun ships native sql.listen, delete this package
This library is a polyfill. Track oven-sh/bun#18214 — when it lands, migration is a small codemod. Mapping against PR #25511 as currently drafted:
- import { PgListener } from "bun-pg-listen";
- const listener = new PgListener({ onReconnect: replay });
- await listener.connect();
- const unlisten = await listener.listen(channel, cb);
- await listener.notify(channel, payload);
- await listener.close();
+ import { sql } from "bun";
+ const unlisten = await sql.listen(channel, cb);
+ await sql.unsafe(`NOTIFY ${channel}, ${sql.quote(payload)}`);
+ await sql.close();A few features aren't in Bun's proposal yet: onReconnect hook, onError / onNotice callbacks, explicit connect(), configurable backoff. If you depend on them, weigh carefully before migrating — or file them upstream.
Guarantees
listen(channel, cb)resolves only after Postgres acknowledgesLISTEN. Callbacks registered before theawaitresume — a notification pipelined in the same TCP read as the ack is delivered, not dropped.notify(channel, payload)resolves only after the server acksNOTIFY.- Payloads over 7999 bytes throw
PayloadTooLargeErrorclient-side — nothing is sent. - On reconnect, every active channel is re-
LISTENed beforeonReconnectfires. Your replay logic can assume subscriptions are restored. - Pending
listen()/notify()/unlisten()promises reject withConnectionClosedErrorwhen the connection drops. - Identifiers are rejected if they exceed 63 bytes (Postgres's
NAMEDATALEN - 1) — silent truncation would cross-wire channels.
Non-guarantees
- At-most-once delivery. Postgres
LISTEN/NOTIFYloses notifications on connection drops. Use a durable side-table with monotonic IDs and reconcile inonReconnectif you need reliability. - No channel binding (
SCRAM-SHA-256-PLUS). V1 supports plainSCRAM-SHA-256over TLS. None of the major managed providers (RDS, Cloud SQL, Azure, Neon, Supabase) require it. - Separate connection.
PgListeneropens its own socket — it doesn't shareBun.sql's pool. Budget +1 connection per listener against your server'smax_connections.
Commands
| Command | What it does |
| ---------------------- | ----------------------------------------------- |
| bun test | Unit tests (no Postgres required) |
| bun test:integration | Integration tests (needs Postgres — see below) |
| bun test:all | Both |
| bun run check | Biome + knip + tsc |
| bun run db:up | Start a Postgres 18 container with SCRAM forced |
| bun run db:down | Stop and wipe it |
Architecture
Four layers, bottom up:
src/protocol/— pure binary codecs.Parserbuffers partial TCP reads and emits tagged-union backend messages;serializer.tswrites frontend messages. No I/O, no state beyond the read buffer.src/connection.ts— owns theBun.TCPSocket, runs the startup handshake (including SCRAM fromsrc/scram.ts), and dispatches decoded messages to handler callbacks.src/listener.ts— publicPgListenerAPI. Channel → callbacks map, a FIFOpendingOpsqueue correlatingCommandComplete/ErrorResponse/ReadyForQueryto in-flight promises, exponential-backoff reconnect, re-LISTENon reconnect.src/config.ts— resolves aPgConfigfrom a connection string, inline options, and env vars. SSL-mode resolution lives here.
License
MIT — see LICENSE.
