npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@agentcash/router

v0.6.8

Published

Unified route builder for Next.js App Router APIs with x402, MPP, SIWX, and API key auth

Readme

@agentcash/router

Unified route builder for Next.js App Router APIs with x402 payments, MPP payments, SIWX authentication, and API key auth.

Eliminates ~80-150 lines of boilerplate per route. Routes become 3-6 lines.

Install

pnpm add @agentcash/router

Peer dependencies:

pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi
# Optional: for MPP support
pnpm add mppx

Environment Setup

The router uses the default facilitator from @coinbase/x402 for x402 payments, which requires CDP API keys:

CDP_API_KEY_ID=your-key-id
CDP_API_KEY_SECRET=your-key-secret

For Next.js apps with env validation (T3 stack, @t3-oss/env-nextjs): Add these to your env schema — Next.js doesn't expose undeclared env vars to process.env.

// src/env.js
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  server: {
    CDP_API_KEY_ID: z.string(),
    CDP_API_KEY_SECRET: z.string(),
  },
  runtimeEnv: {
    CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
    CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
  },
});

Without these keys, x402 routes will fail to initialize (empty 402 responses, no payment header).

Quick Start

1. Create the router (once per service)

// lib/routes.ts
import { createRouter } from '@agentcash/router';

export const router = createRouter({
  payeeAddress: process.env.X402_PAYEE_ADDRESS!,
});

2. Define routes

Paid route (x402)

// app/api/search/route.ts
import { router } from '@/lib/routes';
import { searchSchema, searchResponseSchema } from '@/lib/schemas';

export const POST = router.route('search')
  .paid('0.01')
  .body(searchSchema)
  .output(searchResponseSchema)
  .description('Search the web')
  .handler(async ({ body }) => search(body));

SIWX-authenticated route

export const GET = router.route('inbox/status')
  .siwx()
  .query(statusQuerySchema)
  .handler(async ({ query, wallet }) => getStatus(query, wallet));

Unprotected route

export const GET = router.route('health')
  .unprotected()
  .handler(async () => ({ status: 'ok' }));

3. Auto-discovery

// app/.well-known/x402/route.ts
import { router } from '@/lib/routes';
import '@/lib/routes/barrel'; // ensures all routes are imported
export const GET = router.wellKnown();

// app/openapi.json/route.ts
import { router } from '@/lib/routes';
import '@/lib/routes/barrel';
export const GET = router.openapi({ title: 'My API', version: '1.0.0' });

API

createRouter(config)

Creates a ServiceRouter instance.

| Option | Type | Default | Description | |--------|------|---------|-------------| | payeeAddress | string | required | Wallet address to receive payments | | network | string | 'eip155:8453' | Blockchain network | | plugin | RouterPlugin | undefined | Observability plugin | | prices | Record<string, string> | undefined | Central pricing map (auto-applied) | | siwx.nonceStore | NonceStore | MemoryNonceStore | Custom nonce store | | mpp | { secretKey, currency, recipient? } | undefined | MPP config |

Route Builder

The fluent builder ensures compile-time safety:

  • .paid(price) / .paid(fn, { maxPrice }) / .paid({ field, tiers }) - Payment auth
  • .siwx() - SIWX wallet auth
  • .apiKey(resolver) - API key auth (composable with .paid())
  • .unprotected() - No auth
  • .body(zodSchema) - Request body validation
  • .query(zodSchema) - Query parameter validation
  • .output(zodSchema) - Response schema (for OpenAPI)
  • .description(text) - Route description (for OpenAPI)
  • .provider(name, config?) - Provider monitoring (see Provider Monitoring)
  • .handler(fn) - Terminal method, returns Next.js handler

Pricing Modes

Static - Fixed price for all requests:

router.route('search').paid('0.02')

Dynamic - Calculate price based on request body:

router.route('gen')
  .paid((body) => calculateCost(body.imageSize, body.quality))
  .body(imageGenSchema)
  .handler(async ({ body }) => generate(body));

Dynamic with safety net - Cap at maxPrice if calculation exceeds, fallback to maxPrice on errors:

router.route('compute')
  .paid((body) => calculateExpensiveOperation(body), { maxPrice: '10.00' })
  .body(computeSchema)
  .handler(async ({ body }) => compute(body));

Tiered - Price based on a specific field value:

router.route('upload').paid({
  field: 'tier',
  tiers: {
    '10mb': { price: '0.02', label: '10 MB' },
    '100mb': { price: '0.20', label: '100 MB' },
  },
}).body(uploadSchema)

maxPrice Semantics (v0.3.1+)

maxPrice is optional for dynamic pricing and acts as a safety net:

  1. Capping: If calculateCost(body) returns "15.00" but maxPrice: "10.00", the client is charged $10.00 (capped) and a warning alert fires.

  2. Fallback: If calculateCost(body) throws an error and maxPrice is set, the route falls back to maxPrice (degraded mode) and an alert fires. Without maxPrice, the route returns 500.

  3. Trust mode: No maxPrice means full trust in your pricing function (no cap, no fallback).

Best practices:

  • ✅ Always set maxPrice for production routes (safety net)
  • ✅ Use maxPrice for routes with external dependencies (pricing APIs)
  • ✅ Monitor alerts for capping events (indicates pricing bug)
  • ⚠️ Skip maxPrice only for well-tested, unbounded pricing (e.g., per-GB storage)

Example with safety net:

router.route('ai-gen')
  .paid(async (body) => {
    // External pricing API (can fail)
    const res = await fetch('https://pricing.example.com/calculate', {
      method: 'POST',
      body: JSON.stringify(body),
    });
    return res.json().price;
  }, { maxPrice: '5.00' })  // Fallback if API is down
  .body(genSchema)
  .handler(async ({ body }) => generate(body));

Dual Protocol (x402 + MPP)

router.route('search')
  .paid('0.01', { protocols: ['x402', 'mpp'] })
  .body(schema)
  .handler(fn);

Handler Context

interface HandlerContext<TBody, TQuery> {
  body: TBody;              // Parsed + validated
  query: TQuery;            // Parsed + validated
  request: NextRequest;     // Raw request
  wallet: string | null;    // Verified wallet address
  account: unknown;         // From .apiKey() resolver
  alert: AlertFn;           // Fire observability alerts
  setVerifiedWallet: (addr: string) => void;
}

RouterPlugin

Pluggable observability. All hooks are optional and fire-and-forget.

import { createRouter, type RouterPlugin } from '@agentcash/router';

const myPlugin: RouterPlugin = {
  onRequest(meta) { /* ... */ },
  onPaymentVerified(ctx, payment) { /* ... */ },
  onPaymentSettled(ctx, settlement) { /* ... */ },
  onResponse(ctx, response) { /* ... */ },
  onError(ctx, error) { /* ... */ },
  onAlert(ctx, alert) { /* ... */ },
  onProviderQuota(ctx, event) { /* ... */ },
};

export const router = createRouter({
  payeeAddress: process.env.X402_PAYEE_ADDRESS!,
  plugin: myPlugin,
});

Built-in consolePlugin() logs lifecycle events:

import { createRouter, consolePlugin } from '@agentcash/router';

export const router = createRouter({
  payeeAddress: process.env.X402_PAYEE_ADDRESS!,
  plugin: consolePlugin(),
});

Central Pricing Map

For services with many static-priced routes:

const router = createRouter({
  payeeAddress: process.env.X402_PAYEE_ADDRESS!,
  prices: {
    'search': '0.02',
    'lookup': '0.05',
  },
});

// Price auto-applied, no .paid() needed
export const POST = router.route('search')
  .body(schema)
  .handler(fn);

Provider Monitoring

Routes that wrap third-party APIs can declare monitoring behavior per-provider. This surfaces quota/balance information through the plugin system and registers cron-checkable monitors.

Why

Upstream providers report remaining quota in different ways:

| Pattern | Example | How detected | |---------|---------|-------------| | Balance in response headers | X-RateLimit-Remaining: 482 | extractQuota reads headers | | Balance in response body | { rateLimit: { remaining: 50 } } | extractQuota reads result | | Separate health-check endpoint | Apollo /credits endpoint | monitor function (cron) | | Overages auto-charged at same rate | Exa, Firecrawl | overage: 'same-rate' | | Overages at increased rate | Some SaaS APIs | overage: 'increased-rate' | | No overages, immediate stoppage | Whitepages | overage: 'hard-stop' |

The .provider() method handles all six patterns through a single interface.

Basic usage

export const POST = router.route('search')
  .paid('0.01')
  .provider('exa', {
    extractQuota: (result, headers) => ({
      remaining: (result as any).rateLimit?.remaining ?? null,
      limit: (result as any).rateLimit?.limit ?? null,
    }),
    warn: 100,
    critical: 10,
  })
  .body(searchSchema)
  .handler(async ({ body }) => exaClient.search(body));

After every successful handler response, extractQuota runs with the raw handler result and the response headers. The router computes a level (healthy, warn, critical) based on thresholds and fires onProviderQuota on the plugin.

ProviderConfig

| Field | Type | Default | Description | |-------|------|---------|-------------| | extractQuota | (result, headers) => QuotaInfo \| null | — | Inline quota extraction after each request | | monitor | () => Promise<QuotaInfo \| null> | — | Standalone health check (for cron) | | overage | 'same-rate' \| 'increased-rate' \| 'hard-stop' | 'same-rate' | What happens when quota hits zero | | warn | number | — | Fire warn level when remaining <= this | | critical | number | — | Fire critical level when remaining <= this |

QuotaInfo

interface QuotaInfo {
  remaining: number | null;  // Credits/calls remaining
  limit: number | null;      // Total quota (null if unknown)
  spend?: number;             // Credits consumed this request
}

Threshold logic

| Condition | Level | |-----------|-------| | remaining === null | healthy (no data to compare) | | remaining <= critical | critical | | remaining <= warn | warn | | Otherwise | healthy |

Plugin hook

interface ProviderQuotaEvent {
  provider: string;       // Provider name from .provider()
  route: string;          // Route key
  remaining: number | null;
  limit: number | null;
  spend?: number;
  level: 'healthy' | 'warn' | 'critical';
  overage: 'same-rate' | 'increased-rate' | 'hard-stop';
  message: string;        // Human-readable summary
}

Handle in your plugin:

const myPlugin: RouterPlugin = {
  onProviderQuota(ctx, event) {
    if (event.level === 'critical') {
      discord.alert(`${event.provider}: ${event.remaining} remaining`);
    }
    clickhouse.insert('provider_quota', event);
  },
};

Cron monitors

For providers that require a separate API call to check balance (not available inline in response), register a monitor function:

export const POST = router.route('people/search')
  .paid('0.05')
  .provider('apollo', {
    monitor: async () => {
      const res = await fetch('https://api.apollo.io/v1/credits', {
        headers: { 'X-Api-Key': process.env.APOLLO_KEY! },
      });
      const data = await res.json();
      return { remaining: data.credits, limit: null };
    },
    overage: 'hard-stop',
    warn: 500,
    critical: 50,
  })
  .body(searchSchema)
  .handler(fn);

Retrieve all registered monitors via router.monitors():

// cron.ts — run every 5 minutes
import { router } from '@/lib/routes';
import '@/lib/routes/barrel';

for (const entry of router.monitors()) {
  const quota = await entry.monitor();
  if (!quota) continue;

  const level = quota.remaining !== null && quota.remaining <= (entry.critical ?? 0)
    ? 'critical'
    : quota.remaining !== null && quota.remaining <= (entry.warn ?? 0)
      ? 'warn'
      : 'healthy';

  if (level !== 'healthy') {
    alert(`${entry.provider} (${entry.route}): ${quota.remaining} remaining [${level}]`);
  }
}

monitors() returns:

interface MonitorEntry {
  provider: string;
  route: string;
  monitor: () => Promise<QuotaInfo | null>;
  overage: OveragePolicy;
  warn?: number;
  critical?: number;
}

Provider name only

If you just want to tag a route with its provider for logging/tracing, pass only the name:

router.route('health')
  .unprotected()
  .provider('internal')
  .handler(async () => ({ status: 'ok' }));

Safety guarantees

  • extractQuota runs fire-and-forget — exceptions are caught and swallowed
  • extractQuota only runs when response.status < 400 (no quota extraction on errors)
  • The plugin hook is non-blocking — it never delays the response to the caller
  • Missing thresholds are fine — without warn/critical, level is always healthy

License

MIT