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 hereIt 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
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.
| Option | Default | What |
|---|---|---|
basePath | "/api/push" | Must match your server's |
baseURL | same origin | For 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>| Option | Default | What |
|---|---|---|
capacity | 500 | Notifications 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",
}),
});