Releases / Changelog
Changelog
Every release, what changed in it, and why — including the bugs worth knowing about.
Releases
0.7.0current
Telegram as a channel: a bot the user connects with one tap.
Changed
Channelgainstelegram; preferences lists itThe union is closed, so the one core change is the new member.
Added
telegramplugin +telegramBot()providerFromeasy-ping/plugins/telegramandeasy-ping/providers/telegram.POST /telegram/linkreturns a one-timet.me/<bot>?start=<code>link; the user taps it, the bot stores the chat, and every send withtelegramin its channels reaches it. HTML messages with an optional inline button, link previews off. Updates arrive through a webhook authenticated by Telegram's secret-token header, or throughplugin.poll()where there is no public URL.- Failure classification on the Bot API429 retries with Telegram's
retry_after; 403 and “chat not found” prune the chat on the spot so the next send skips instead of failing; malformed HTML fails without retry and keeps the chat. The bot token never appears in an error or a log line.
0.6.1
Security review of the event-driven transport: stream caps, scoped tab groups, shared probes.
Security
scopekeeps tabs signed in as different users apartThe leader lock and tab channel were named after the mount alone, so when identity is not a cookie (a header your custom fetch adds) two users' tabs could share a leader and mirror each other's inbox. Passscope: userId; cookie-based apps need nothing.- Event streams are capped per user and per process
events.maxStreamsPerUser(10) answers a 429 with Retry-After;events.maxStreams(5000) answers a 503. One valid session could previously hold every socket and timer. A stream whose consumer stops reading is closed after 256 unread chunks. sweepOnRequestonly after served requests; inbox header only from the page's originAnonymous 401s no longer trigger a delivery pass, andinstrument(fetch)ignores the inbox-version header on third-party responses.
Fixed
- One database probe per user, however many streams they holdWithout a cross-process signal each stream probed on its own timer, so ten tabs meant twenty queries per interval. Probes are now shared and reference-counted.
Changed
- Dev tooling upgraded past published advisoriesvitest, vite, tsup, esbuild and changesets. The production dependency tree had none; the package ships one runtime dependency.
0.6.0
The bell no longer lives on a timer: one event stream per browser, wake-ups instead of polls.
Fixed
toNodeHandlerstreams response bodies and aborts on client disconnectIt used to buffer the whole body before writing, which every JSON route survived and the event stream did not: through Express or plain Node the browser never saw a byte. Found by running the built client against the demo server over real HTTP.
Changed
- The client holds one stream per browser and polls only as a fallbackA
navigator.locksleader owns the connection; other tabs mirror its state overBroadcastChannel, so ten tabs cost one connection and zero idle queries. If a host cuts three streams in a row the session falls back to polling, which now backs off while the user is idle and snaps back on input.transport: "poll"restores the old behaviour. startWorker()is woken bysend(); the default idle interval is 10 sThe loop no longer claims every second. A send in the same process (or, with a signal, any process) wakes it at once, so the interval only bounds work committed elsewhere.
Added
GET /events— a server-sent event stream per userreadyonce, thenchangedwhenever this user's inbox moves: after a send, a /read, a /seen. No payload travels on it; the client refetches the first page.events: falseremoves the route. Keepalive every 25 s, optionalmaxDurationMsfor hosts with a cap.signals— the wake-up seam, with Postgres and MongoDB implementationspostgresSignals(sql)over LISTEN/NOTIFY andmongoSignals(db)over a change stream carry "something changed, go look" across replicas. In-memory by default; without a cross-process signal each stream probes a cheap inbox fingerprint every 30 s.delivery.sweepOnRequest— deliver on the next page load, not the next cronA bounded pass after any request, at most every 5 s per process. For free-tier hosts that see traffic but no scheduler for minutes at a time. Ignored ininlinemode.notify.inboxHeaders(userId)+client.instrument(fetch), andeasy-ping/swYour own API responses can carry the inbox version and the bell refreshes only when it moves.handlePush(event)from the new service-worker entry shows the OS notification and relays achangedmessage to open tabs.
0.5.0
MySQL and SQLite, on the same conformance suite as everything else.
Fixed
- A concurrent lock-free MySQL claim could deadlockInnoDB rolls the loser back and asks for a retry; the adapter now does so, bounded and jittered. Found by the eight-caller conformance case on the first full run.
Changed
- Six backends run every suiteThe conformance suite grew from 44 to 87 cases and the whole suite from 437 to 604 tests without a new assertion: the two MySQL paths and SQLite run the existing ones. The plugin store now reads 0/1 back as booleans for engines without a boolean column.
Added
mysqlAdapter— MySQL 8 or MariaDB through any driverFromeasy-ping/adapters/mysql, withmysql2Query,mysqlTransactionandcreateMysqlTables. With a transaction a claim uses FOR UPDATE SKIP LOCKED; without one it is a single lock-free UPDATE that re-checks eligibility on the locked row, so two sweeps can never take the same delivery. Create the pool withtimezone: "Z".sqliteAdapter— node:sqlite, better-sqlite3 or anything with the same shapeFromeasy-ping/adapters/sqlite, withsqliteQuery,sqliteTransactionandcreateSqliteTables. SQLite has one writer, so the claim is one UPDATE. Nothing is imported at module level, so the Node 20 floor holds.renderMysqlDdlandrenderSqliteDdlMySQL string columns are sized to InnoDB's 3072-byte key limit and indexes are declared inline, since MySQL has no CREATE INDEX IF NOT EXISTS. Bootstrap only: no migration planner for these dialects yet.
0.4.0
A security review of 0.3.0, and every finding from it closed. Two changes break 0.3.0 consumers: tokens are re-keyed, and plugins no longer receive the secret.
Security
- A push endpoint belongs to one accountRegistration upserted on
endpointalone and rewroteuser_id, so anyone who knew a device's endpoint URL could re-home it and silently take its pushes. Another account registering an owned endpoint is now a 409; the owner can still refresh their keys. - CSRF defence on every POST
application/jsonis required (415) and a cross-originOriginis refused (403) unless listed in the newtrustedOrigins. Holds even with aSameSite=Nonecookie. The signed/unsubscriberoute stays form-tolerant for one-click mail clients. - Push endpoints are validated at registrationPublic https only, no loopback or private hosts, well-formed 65/16-byte keys, optional
allowedEndpointHosts. Closes the server-side request the plugin made to any URL a user supplied. Unusable subscriptions are pruned instead of retried five times. - Secrets must be real
secret,cron.secretand the newmachineSecretmust be 16+ characters and not a placeholder, or startup throws. A one-character HMAC key made every unsubscribe link forgeable offline. - Plugins get
sign(), not the secretKeys are derived per purpose with HKDF and a plugin can only mint tokens for purposes its own signed routes declare. Every 0.3.0 token stops verifying; re-issue unsubscribe links. - Unsubscribe links cannot undo a newer decisionTokens carry their issue time and
notification_preferencegainsupdatedAt. A link older than the user's last explicit change is refused; a repeat click is still a 200.
Fixed
- A thrown adapter error no longer crashes the Node process
toNodeHandlerrethrew after responding, which under Express is an unhandled rejection. The handler now returns a logged 500 everywhere, andtoNodeHandlertakesonError. - A throttled push device was counted as deliveredA 429 from the push service marked the delivery sent. It is now retryable, and fan-out runs in parallel so one dead endpoint cannot hold the sweep to the timeout.
- Date columns came back as strings through Drizzle over postgres.jsThe plugin store now coerces declared date fields, matching the other adapters.
Changed
- Bodies capped, responses private, errors redacted64 KiB default via
maxBodyBytesand the newreadJsonBody;Cache-Control: private, no-storeandnosniffon every response; email addresses stripped fromlast_error;basePathanchored to a path segment; the bearer compare no longer leaks secret length. - Preference writes are validated
typemust be a configured notification,channelandfrequencyvalid,enableda boolean. The plugin store also refuses non-scalar values, so a JSON body can no longer carry a Mongo operator into a query.
Added
rateLimit,onRequest,machineSecret,cron.maxSweeps,listRoutes()An in-process limiter before routing, a hook for your own, a separate credential for plugin machine routes, a bound on one cron drain, and the route inventory RFC 0002 promised.custom-scoped routes are named in a startup warning.escapeHtml,maxDevicesPerUserThe quickstart template now escapes user input; each user keeps at most 20 devices by default. The repo's own examples and demo were fixed to match.
0.3.0
What building a real app on 0.2.0 turned up.
Fixed
- Docs: the route mount was wrong, and it broke the feedThe quickstart said
[...notify]. Next's required catch-all does not match the bare /api/notifications path, which is where the feed lives, so the bell rendered an empty list against a 404 while /count kept working. It is[[...notify]]. - An opt-out is no longer recorded as a delivery failureA plugin can now return { result: 'skipped', reason }, writing the skipped status the schema always had. Push uses it when nobody has registered a device, so getFailedDeliveries stops filling with people who never enabled push and starts showing only real breakage.
Changed
- A poll costs one request instead of twoThe feed carries unseenCount on the first page and the client no longer calls /count beside it. At a thousand open tabs on the default interval that halves 133 req/s of pure badge-keeping. Cursor pages omit it, being scrollback rather than a poll.
Added
- createPostgresTables and pgTransactionThe bootstrap file and the sixteen-line transaction wrapper that every integration was writing by hand now ship. pushSchema is exported standalone too, so rendering plugin DDL no longer means constructing a plugin with a provider you never intend to call.
0.2.0
Postgres without an ORM.
Fixed
- Docs: every code sample had its indentation eatenMDX strips up to two leading spaces from each line of a multi-line JSX expression, and the samples were written as template literals inside one — so nested code on all 23 pages rendered flat. Samples are fenced now, which MDX passes through byte-exactly.
- Docs:
send()does block ininlinemodeEvery other mode returns as soon as the rows are committed, butinlineawaits that send's own deliveries. The docs previously claimed it never blocks, full stop. - Docs: a missing
cron.secretthrows at startupIt does not silently 404, which is what the docs said.easyPing()refuses to construct rather than mount an unauthenticated flush-everything endpoint. - Docs: the configuration reference was incomplete
basePath,tablePrefix,leaseMs,batchSizeandthrowOnErrorwere missing from a page that claimed to list every option, andbackoffcan be a function.
Changed
- The conformance suite now runs three backends, not twopostgres-drizzle, postgres-raw and mongodb. The test count went from 304 to 359 without a single new assertion being written — the new adapter simply runs the existing behaviour suites.
Added
postgresAdapter— Postgres through any driverTakes a plain query function instead of an ORM instance, sopg,postgres.js, Kysely, Neon and PlanetScale's serverless drivers all work with no ORM in the dependency tree. Verified against the same 14 conformance cases as the Drizzle and MongoDB adapters, including the FOR UPDATE SKIP LOCKED one.
0.1.0
First release.
Security
- Every route carries a scope, enforced before the handler runsA user-scoped handler receives an already-resolved userId and never sees a client-supplied one. Feed and read queries are additionally scoped by user id in the query itself.
Fixed
createdAtcame from the database clock, not the app'smarkSeencompared a database timestamp against an app-clock cutoff. On one machine they agree; across two hosts, NTP skew meant the newest notifications were never marked seen and the badge never cleared. Found because MongoDB did it the other way.
Added
- The
send()pipelineDedupe, hooks, four delivery modes over one cron sweep, and retry with exponential backoff and jitter. - Atomic claiming, so two sweeps never send the same thing twiceFOR UPDATE SKIP LOCKED on Postgres, an atomic findOneAndUpdate loop on MongoDB. Both proven by a conformance case that holds a real row lock and asserts the claim skips it.
- Two databases — Postgres via Drizzle, and MongoDBAdding the second one is what exposed that the plugin store had hard-coded Postgres naming and JSON handling; those are now the adapter's declaration.
- In-app inbox, email (Resend), and web pushPush is VAPID and aes128gcm on Web Crypto — no node:crypto — so it runs on Workers and Edge, which the web-push npm package cannot.
- preferences, digests and push pluginsEach owns its own tables through a scoped store it cannot read outside of.
How versions work
Semantic versioning, with the pre-1.0 caveat that minor versions may still move APIs. The status note on the introduction says which parts are settled.
Every release is cut with changesets, so
packages/easy-ping/CHANGELOG.md in the library repo is the machine-generated record. This page
is the human one — it exists to say why something changed, which a commit log never does.
Documentation errors are listed here as fixes, not hidden. A wrong claim in the docs is a bug: it sends you down the wrong path just as surely as wrong code, and with these docs it also feeds the AI prompts, where a confident wrong answer propagates straight into someone's codebase.