npm package: @nkemtasoft/local-push
Description: Local Web Push notification testing service — like Mailpit, but for VAPID web push
Latest version: 0.1.0

# local-push

Local Web Push notification testing service — like [Mailpit](https://github.com/axllent/mailpit) but for VAPID web push.

Run it locally or in Docker, point your push subscriptions at it, and inspect every notification your backend sends — **zero changes to your application code**.

<p align="center">
  <img src="https://img.shields.io/npm/v/@nkemtasoft/local-push" alt="npm version" />
  <img src="https://img.shields.io/github/license/nkemtasoft/local-push" alt="license" />
  <img src="https://github.com/nkemtasoft/local-push/actions/workflows/ci.yml/badge.svg" alt="CI" />
</p>

---

## How it works

The `web-push` npm library encrypts your notification payload and POSTs it to whatever URL is in the subscription's `endpoint` field. **local-push** generates subscriptions whose endpoints point to itself, so it intercepts, decrypts, stores, and displays every push notification — no browser or third-party push service required.

```
┌──────────────┐   webpush.sendNotification()   ┌──────────────┐
│  Your        │ ──────────────────────────────► │  local-push  │
│  Backend     │   (encrypted POST to endpoint)  │  Server      │
└──────────────┘                                 └──────┬───────┘
                                                        │
                                                 decrypt & store
                                                        │
                                                 ┌──────▼───────┐
                                                 │  Dashboard   │
                                                 │  + REST API  │
                                                 └──────────────┘
```

## Quick Start

### Docker (recommended)

```bash
docker run -p 4078:4078 ghcr.io/nkemtasoft/local-push
```

Open https://localhost:4078 in your browser to see the dashboard.

### Docker Compose

```yaml
services:
  local-push:
    image: ghcr.io/nkemtasoft/local-push
    ports:
      - "4078:4078"

  your-backend:
    # ...
    environment:
      # Accept the self-signed TLS certificate
      - NODE_TLS_REJECT_UNAUTHORIZED=0
```

### npx

```bash
npx @nkemtasoft/local-push
```

## Client SDK

Install the package in your project (typically as a dev dependency):

```bash
npm install -D @nkemtasoft/local-push
# or
yarn add -D @nkemtasoft/local-push
```

### Usage

```typescript
import { LocalWebPushClient } from "@nkemtasoft/local-push/client";

const client = new LocalWebPushClient({ url: "https://localhost:4078" });

// Create a subscription (returns standard PushSubscription format)
const { subscription, id } = await client.createSubscription();
// → { endpoint: "https://localhost:4078/push/uuid", keys: { p256dh: "...", auth: "..." } }

// Insert into your database for the test user
await db.webpushSubscriptions.create({
  user: testUserId,
  endpoint: subscription.endpoint,
  keys: subscription.keys,
});

// Your backend sends push notifications as normal — no code changes needed
await yourBackend.sendNotification(testUserId, { title: "Hello" });

// Check what was received
const msg = await client.waitForMessage({ timeout: 5000 });
console.log(msg.payload); // { title: "Hello" }
```

### SDK API

```typescript
// Subscriptions
client.createSubscription(baseUrl?: string): Promise<CreateSubscriptionResponse>
client.listSubscriptions(): Promise<SubscriptionInfo[]>
client.getSubscription(id: string): Promise<SubscriptionInfo>
client.deleteSubscription(id: string): Promise<void>
client.deleteAllSubscriptions(): Promise<void>

// Messages
client.getMessages(filter?: MessageFilter): Promise<MessageListResponse>
client.getMessage(id: string): Promise<StoredMessage>
client.deleteMessage(id: string): Promise<void>
client.deleteAllMessages(): Promise<void>

// Testing utilities
client.waitForMessage(options?: WaitOptions): Promise<StoredMessage>
client.reset(): Promise<void>
```

**`waitForMessage`** polls the API until a new message arrives or the timeout is reached. Useful in integration tests:

```typescript
const msgPromise = client.waitForMessage({ timeout: 5000 });
await webpush.sendNotification(subscription, JSON.stringify(payload));
const msg = await msgPromise;

expect(msg.payload).toEqual(payload);
```

## REST API

All endpoints are under `/api/v1`.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/health` | Health check |
| `POST` | `/api/v1/subscriptions` | Create a subscription |
| `GET` | `/api/v1/subscriptions` | List all subscriptions |
| `GET` | `/api/v1/subscriptions/:id` | Get a subscription |
| `DELETE` | `/api/v1/subscriptions/:id` | Delete a subscription |
| `DELETE` | `/api/v1/subscriptions` | Delete all subscriptions |
| `GET` | `/api/v1/messages` | List messages (`?subscriptionId=&limit=&offset=`) |
| `GET` | `/api/v1/messages/:id` | Get a message |
| `DELETE` | `/api/v1/messages/:id` | Delete a message |
| `DELETE` | `/api/v1/messages` | Delete all messages |
| `GET` | `/api/v1/events` | SSE stream (real-time updates) |

### Create Subscription

```bash
curl -k -X POST https://localhost:4078/api/v1/subscriptions \
  -H "Content-Type: application/json" \
  -d '{"baseUrl": "https://localhost:4078"}'
```

Response:

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "subscription": {
    "endpoint": "https://localhost:4078/push/550e8400-e29b-41d4-a716-446655440000",
    "keys": {
      "p256dh": "BLa5...",
      "auth": "kQ3..."
    }
  },
  "createdAt": "2025-01-01T00:00:00.000Z"
}
```

## Dashboard

The built-in web dashboard (served at the root URL) provides:

- Real-time message list with auto-updates via SSE
- Click-to-inspect with full JSON payload, headers, and metadata
- Subscription filter
- Clear all button
- Dark/light mode (follows system preference)

## Configuration

| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `PORT` | `4078` | Server port |
| `MAX_MESSAGES` | `1000` | Maximum stored messages (FIFO eviction) |
| `BASE_URL` | auto-detect | Base URL for generated subscription endpoints |
| `LOG_LEVEL` | `info` | Log verbosity |

## TLS / Self-Signed Certificate

The `web-push` npm library always uses HTTPS, so **local-push** generates a self-signed TLS certificate on startup.

**For your backend services**, set the environment variable:

```
NODE_TLS_REJECT_UNAUTHORIZED=0
```

This is standard practice for local development and requires **no code changes** to your application.

**For browsers**, you'll see a certificate warning when opening the dashboard — click through it to proceed.

## Integration Example

Here's a full integration test using `web-push` and `vitest`:

```typescript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import webpush from "web-push";
import { LocalWebPushClient } from "@nkemtasoft/local-push/client";

process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

const client = new LocalWebPushClient({ url: "https://localhost:4078" });

beforeAll(() => {
  webpush.setVapidDetails(
    "mailto:test@example.com",
    process.env.VAPID_PUBLIC_KEY!,
    process.env.VAPID_PRIVATE_KEY!,
  );
});

describe("push notifications", () => {
  it("sends and receives a notification", async () => {
    await client.reset();
    const { subscription } = await client.createSubscription();

    const payload = { title: "Hello", body: "World" };

    const msgPromise = client.waitForMessage({ timeout: 5000 });
    await webpush.sendNotification(subscription, JSON.stringify(payload));
    const msg = await msgPromise;

    expect(msg.payload).toEqual(payload);
  });
});
```

## Development

```bash
git clone https://github.com/nkemtasoft/local-push.git
cd local-push
yarn install
yarn dev      # Start with hot reload
yarn test     # Run tests
yarn build    # Compile TypeScript
```

## License

MIT