easy-pingv0.7.0

Get in touch

Questions, bug reports, or anything about easy-ping. Either of these reaches me.

Emailteklumo.jembere@gmail.comTelegram@teklumt

For anything others would benefit from, a GitHub issue is better than a DM, because it's searchable.

GitHub

Releases / Changelog

Edit this page

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

  • Channel gains telegram; preferences lists itThe union is closed, so the one core change is the new member.

Added

  • telegram plugin + telegramBot() providerFrom easy-ping/plugins/telegram and easy-ping/providers/telegram. POST /telegram/link returns a one-time t.me/<bot>?start=<code> link; the user taps it, the bot stores the chat, and every send with telegram in 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 through plugin.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

  • scope keeps 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. Pass scope: userId; cookie-based apps need nothing.
  • Event streams are capped per user and per processevents.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.
  • sweepOnRequest only after served requests; inbox header only from the page's originAnonymous 401s no longer trigger a delivery pass, and instrument(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

  • toNodeHandler streams 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.locks leader owns the connection; other tabs mirror its state over BroadcastChannel, 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 by send(); 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 userready once, then changed whenever this user's inbox moves: after a send, a /read, a /seen. No payload travels on it; the client refetches the first page. events: false removes the route. Keepalive every 25 s, optional maxDurationMs for hosts with a cap.
  • signals — the wake-up seam, with Postgres and MongoDB implementationspostgresSignals(sql) over LISTEN/NOTIFY and mongoSignals(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 in inline mode.
  • notify.inboxHeaders(userId) + client.instrument(fetch), and easy-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 a changed message 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 driverFrom easy-ping/adapters/mysql, with mysql2Query, mysqlTransaction and createMysqlTables. 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 with timezone: "Z".
  • sqliteAdapter — node:sqlite, better-sqlite3 or anything with the same shapeFrom easy-ping/adapters/sqlite, with sqliteQuery, sqliteTransaction and createSqliteTables. SQLite has one writer, so the claim is one UPDATE. Nothing is imported at module level, so the Node 20 floor holds.
  • renderMysqlDdl and renderSqliteDdlMySQL 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 endpoint alone and rewrote user_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 POSTapplication/json is required (415) and a cross-origin Origin is refused (403) unless listed in the new trustedOrigins. Holds even with a SameSite=None cookie. The signed /unsubscribe route 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 realsecret, cron.secret and the new machineSecret must 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_preference gains updatedAt. 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 processtoNodeHandler rethrew after responding, which under Express is an unhandled rejection. The handler now returns a logged 500 everywhere, and toNodeHandler takes onError.
  • 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 maxBodyBytes and the new readJsonBody; Cache-Control: private, no-store and nosniff on every response; email addresses stripped from last_error; basePath anchored to a path segment; the bearer compare no longer leaks secret length.
  • Preference writes are validatedtype must be a configured notification, channel and frequency valid, enabled a 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 in inline modeEvery other mode returns as soon as the rows are committed, but inline awaits that send's own deliveries. The docs previously claimed it never blocks, full stop.
  • Docs: a missing cron.secret throws 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 incompletebasePath, tablePrefix, leaseMs, batchSize and throwOnError were missing from a page that claimed to list every option, and backoff can 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, so pg, 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

  • createdAt came from the database clock, not the app'smarkSeen compared 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.

Fixes in docs count

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.