better-push
Start here

Emulator

See a notification without a device, a certificate, or a permission prompt.

To see a single push notification normally, you need VAPID keys, a service worker, HTTPS or localhost, a browser permission prompt, and - for anything mobile - a real device with real credentials. Every one of those is a place to stop.

The emulator removes all of them from the first hour. Run one command, register a virtual device, and notify() shows up in a local inbox.

npx @better-push/cli dev
  better-push dev

  inbox      http://127.0.0.1:4984
  provider   add emulator() to your providers, or set
             BETTER_PUSH_EMULATOR_URL=http://127.0.0.1:4984 if you moved this inbox

  bound to localhost only - there is no authentication here

It is a real provider, not a fake

emulator() is a PushProvider like webPush() or apns(). A notification sent to it goes through preferences, digests, delivery rows, the queue, retries and the token lifecycle exactly as one sent to Apple does.

That is the point. An interception layer would show you what you meant; this shows you what the pipeline actually produced - including a delivery that preferences suppressed, or a burst that a digest collapsed into one.

Wiring it up

1. Add the provider

push.ts
import { emulator } from "@better-push/core/providers/emulator";

export const push = betterPush({
  providers: [
    webPush({ vapid }),
    // Development only. `doctor` reports it as an error in production.
    ...(process.env.NODE_ENV === "production" ? [] : [emulator()]),
  ],
  // ...
});

It defaults to BETTER_PUSH_EMULATOR_URL, then http://127.0.0.1:4984.

2. Register a virtual device

"use client";
import { useEmulatorDevice } from "@better-push/core/react";

export function DevTools() {
  const emulator = useEmulatorDevice();
  if (process.env.NODE_ENV === "production") return null;

  return (
    <button onClick={() => void emulator.register()}>
      {emulator.status === "registered"
        ? "Virtual device registered"
        : "Register a virtual device"}
    </button>
  );
}

The hook POSTs to your own /devices endpoint with a random token kept in localStorage. So your own session applies, the row it creates is an ordinary bp_device row, and there is no cross-origin request anywhere - the inbox never talks to the browser, only to your server.

OptionDefaultWhat
basePath"/api/push"Must match your server's
baseURLsame originFor a separate API host
platform"web"What this virtual device stands for; shown in the inbox
deviceName"Emulator"Shown in the device list and the studio

3. Send something

await push.notify({ userId: "alice", title: "Hello from the emulator" });

It appears in the inbox within a second, over SSE, with the rendered title and body, the data, the type, the target device and platform, and the delivery id.

The disconnect switch

Every device in the inbox has a Disconnect button. The next send to it comes back invalid_token with tokenDead, and better-push disables the device - exactly as it would for a real 410 Gone from a push service.

That makes the token lifecycle something you can watch happen in ten seconds rather than something you read about and hope you got right. Reconnect puts it back.

When the inbox is not running

Every delivery fails with network_error and a detail saying to run better-push dev. Nothing throws: a developer who forgot the command sees failed deliveries and a clear message, not a 500 that takes their request down.

network_error is retryable, so with a queue configured the notification simply arrives when the inbox comes back.

Pass emulator({ tolerant: false }) if you would rather it threw.

Mounting the inbox yourself

dev is createServer(toNodeHandler(emulatorInbox().handler)) plus a banner. The inbox is a library surface, so an app that wants it inside its own dev server can mount it:

import { emulatorInbox } from "@better-push/core/emulator";

const inbox = emulatorInbox({ basePath: "/__inbox" });
// inbox.handler is (Request) => Promise<Response>
OptionDefaultWhat
capacity500Notifications kept; the oldest are dropped
basePath"/"Where the inbox is mounted

State is in-memory and per process. It is a dev tool: there is nothing to persist, and nothing to secure beyond binding to loopback.

This is not a push service

Development only

The emulator delivers to a local inbox. It reaches no device, no browser, and no phone. push.diagnose() reports it as an error when NODE_ENV === "production", and the provider logs a warning at startup - because a production deployment that reached this code has a configuration mistake that would otherwise look like a working system delivering nothing.

Native

There is no useEmulatorDevice for React Native yet. The inbox receives whatever the emulator provider is sent, so a native app can register a virtual device by hand:

await fetch(`${API}/api/push/devices`, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
  body: JSON.stringify({
    platform: "ios",
    provider: "emulator",
    token: "emulator-my-simulator",
  }),
});

On this page